A rollback can remove or change shapes the UI still refers to.
- The selection drops shapes that are gone and the color picker that
edited them closes.
- The undo history is cut at the reverted action; it stays when no
entry holds a reverted change. Undo and redo commits carry real undo
changes and the history index they moved from, so a reverted Ctrl+Z
puts the change and the index back.
- An open path edition whose shape the rollback changed closes without
saving the editor's stale copy. The v2 text editor loads the reverted
text again; the WASM editor already showed it.
- The Alt measurements look the hovered shape up in the current data.
- Plugin setters, resize() and rotate() on a removed shape throw
"Shape not found", whatever the plugin's throwValidationErrors flag.
- A save that failed on a short platform failure (a lost or refused
database connection, lack of resources, a server shutting down, a
serialization failure or deadlock, a socket or HTTP timeout, a host
that did not resolve, an interruption) answered 500, which stops
autosave. It now answers 503, which the frontend retries. Other I/O
and database errors keep answering 500, timeouts 504.
- A save answered not-found no longer replaces the workspace with the
access page. It is a terminal save error: the workspace stays open
with the failed save notice, report and reload link. When edit rights
are lost, the notice says how many changes could not be saved.
- A library sync marked the library synced three seconds later,
whatever happened to its save. A rejected sync was undone but stayed
marked: copies stayed out of date and the "updates available" notice
never came back. The mark now waits for the save, a rollback of the
sync brings the notice back, and a failed mark (e.g. the user just
became a viewer) is logged instead of leaving the workspace.
- While saves fail the workspace is read-only. The view-mode banner
says editing is disabled because changes can't be saved, with the
Inspect banner's size and bold lead and no Done button. Users can
still switch pages, pan, zoom and inspect. The lock lifts when a save
lands and brings back the mode the user had or picked meanwhile; the
WebGL context lock keeps the workspace read-only if it lasts longer.
The lock lives in app.main.data.persistence.lock.
- A viewer is never locked. A role change that leaves the user a viewer
keeps the edits that lost permission, queued and on screen, and shows
the count notice again. The role change that brings edit rights back
releases them and rolls them back on screen, as a rejected save would.
Any other terminal failure is released by the next role change or file
opened.
The debug faults `io` (socket timeout, 503) and `eof` (500) cover both
sides of the I/O rule.
A rollback reverts whole undo groups, across files and saves, so that
no user action is left half done. Some actions still split:
- An action that commits several times inside one undo transaction
(e.g. applying a token to several shapes) gave each commit its own
group, so a rejection could revert part of it: shapes ended up linked
to a token whose value was never applied. An undo transaction now
opens with a commit group that every commit made inside it takes.
- The commit that undoes saved commits of a rejected action joined an
open transaction's group, so a later rejection there reverted the
undo too. It gets a group of its own.
- Emptying the open transaction during a rollback dropped its group, so
its later commits were no longer one unit. It keeps its group.
- Waiting for a save ended as soon as the queue emptied, while later
commits could still wait in the buffer. Waiters now also wait for an
empty buffer.
A test covers a token action of three commits in one save whose third
commit is rejected.
An automatic snapshot taken by update-file stores the file data bytes as
they are, instead of realizing every objects-map and encoding the whole
file inside the request. When the data holds objects-maps, the new
compact-file-snapshot task, queued in the same transaction, rewrites the
snapshot with plain maps later. On the legacy-db backend the snapshot is
compacted inline.
fsnap/create! runs its writes in its own transaction, so callers that pass
only a pool (the restore backup) can queue the task. Whether stored bytes
hold objects-maps comes from the decoded data, not from the file features,
and compaction leaves pages behind a pointer unloaded.
update-file checked the schema of the whole file on every save, which
loads and decodes every page even when the changes touch one. On files
with several pages that was most of the time of a save.
validate-file-schema-affected! checks the file data with the pages index
reduced to the pages the changes modify; the data outside pages is still
checked whole, and a change type it does not know makes it check the
whole file. update-file uses it for the strict and the soft schema check.
A save of 10 commits on a file of 10 pages of 2k shapes goes from ~1.1 s
to ~240 ms; a single page file costs the same.
A save whose changes failed validation was rejected as a whole: autosave
stopped, the edits made after it piled up unsent, and a reload lost
them all, the valid ones included.
The frontend now sends each commit of a save as its own group with its
commit id (:changes-with-metadata). When applying and validating all of
them at once fails, the backend searches by bisection for the first
commit that fails, keeps the commits before it and answers with
:rejected (that commit and every later one). Only the kept changes are
stored, snapshotted and broadcast, so other users never see the rejected
ones. Flat :changes and groups without ids stay all-or-nothing; database
and network errors still fail the whole request. A rejection without an
error type is reported as an internal error.
The frontend rolls the file back to what the server holds: it undoes
every pending commit of that file, applies again the remote changes that
arrived meanwhile, and redoes the kept commits. Undoing only the
rejected commits would write stale values over a peer's later change to
the same attribute. The rejected and later commits leave the queue, the
buffer and the undo history, and a warning that hides after ten seconds
tells the user. The pure bookkeeping lives in
app.main.data.persistence.commits.
The pending notice of a commit the rollback dropped no longer leaves the
save status at pending, which made "Pin this version" hang and fail.
Debug tools drive these cases by hand: debug.commitInvalidChange() makes a
save fail validation, holdSaves() delays the answers, failNextSave() and
failNextRollback() fail them on purpose (backend flag debug-save-faults),
and persistenceState() shows the queue.
A remote commit undoes the pending local commits, applies the remote
changes and replays the local ones, so the local file ends like the
server will: remote first, local last. Only queued commits took part;
the ones still waiting in the persistence buffer (up to ~3s of edits)
stayed under the remote change. When both touched the same attribute,
the remote value won locally but lost on the server.
The buffer now lives in the store ([:persistence :buffer]) instead of
an rx buffer, so the rebase can reach it. A flush merges it per file
into queue entries that keep the list of commits they carry, and the
leave-page guard reads buffer and queue from the store. A buffer left
by a previous session is queued when persistence starts.
* 🐛 Skip unknown entries when importing shortcuts
The shortcuts import rejected the whole file when it held a single entry
it did not know, so a profile exported from an older Penpot version, or
edited by hand, could not be imported at all. The spec in #9924 asks to
skip unknown keys, tell the user which ones, and apply the rest.
Importing now keeps the entries that map to a known shortcut with a valid
key combination and skips the rest: unknown contexts, unknown shortcut
keys, and commands that are not a key combination. The import modal
lists the skipped entries. The file is only rejected when it is not a
map or holds nothing that can be applied.
A context whose entries are all skipped is left out: an empty context
clears the user's shortcuts on import, so keeping it would wipe them.
valid-command? reads a command the way mousetrap does, so it accepts
every default shortcut and rejects strings mousetrap could never match.
AI-assisted-by: claude-opus-5-5
* 📎 Sort requires in shortcuts settings test
cljfmt expects the namespace requires in alphabetical order.
AI-assisted-by: claude-opus-5-5
* ✨ Reserve cancel, copy and paste shortcuts
Cancel, copy and paste are no longer customizable, in the workspace and
in the path editor, which shares their shortcut keys. Their key
combinations (Escape, Ctrl/Cmd+C, Ctrl/Cmd+V) are now reserved: the
shortcut recorder warns and blocks saving one of them for another
action, and the import skips entries that use them.
Reserved combinations come from the non-customizable workspace
shortcuts, so marking another one as non-customizable reserves its key
too. The path editor's cancel also binds V, the move tool key, so path
shortcuts are left out. Combinations compare the way mousetrap matches
them: aliases and modifier order do not matter.
apply-custom-overrides now ignores overrides of non-customizable
shortcuts. An override saved before this change would otherwise still
apply, with no way to reset it from the settings page.
AI-assisted-by: claude-opus-5-5
* 🐛 Pass version tag to worker so it loads the current render wasm
* 🐛 Keep wasm thumbnail queue alive when one render fails
* 🐛 Fail wasm thumbnail request when canvas context cannot init
* ✨ Support running several exporter replicas
* 📚 Document one exporter per machine instead of compose replicas
* 📚 Trim exporter replica docs to the essentials
* ✨ Cancel an exporter's jobs when it shuts down
* 📚 Replace exporter replica docs with a short beta note
* 🐛 Fix legacy path migrations on content without coordinates
PRE still returned 500 on get-team-shared-files after #12357, now with
"expected rect instance" in legacy-6. When a path has no points, legacy-6
rebuilds its rect from the content, but content whose points are not
numbers (NaN or infinite coordinates, segments without params, only a
close path) gives no rect either, and rect->points rejects nil.
Fall back to the empty rect in legacy-6. A path with segments missing
coordinates then failed later, in 0003-convert-path-content-v2, since the
binary path data can not store them; drop those segments there.
Relates to #12343
AI-assisted-by: claude-opus-5-5
* 🐛 Skip shared files that fail to load in get-team-shared-files
get-team-shared-files loads and migrates every shared file of the team,
so one file whose migration fails made the whole request return 500 and
the "Add library" modal could not open.
Catch the error per file, log it with the file and team ids, and leave
that file out of the list. The other libraries still show, and the error
report names the broken file.
Closes#12343
AI-assisted-by: claude-opus-5-5
`SET STORAGE DEFAULT` needs PostgreSQL 16, but the official docker
images pin `postgres:15`, so migration 0156 failed on those
deployments. The migration now uses `SET STORAGE EXTENDED`, the
storage mode of the type of every column it touches (text, text[],
jsonb and bytea), which leaves the catalog in the same state on any
supported version.
The documented PostgreSQL floor goes back to >=15, the reason for
raising it was that syntax.
Verified on the test database: the 62 columns reset and no table
column keeps the legacy EXTERNAL storage.
The backend memory now records that migrations must stay within
PostgreSQL 15 syntax.
AI-assisted-by: minimax-m3.1-flash-free
The prod-infra memory now says that the `$host` multi-variable needs
its `query`, its `options` list and its `allValue` regex updated
together. A host present only in `query` is dropped when `All` is
selected, so the panels that filter with `instance=~"$host..."` stop
showing it. Panel 104 is the visible symptom.
AI-assisted-by: minimax-m3.1-flash-free
`app.redis/del` logged `:key key`, but `key` is not bound in that
`let`: it resolved to `clojure.core/key`, so the line carried
`#object[clojure.core$key ...]` instead of the keys that failed to be
deleted. Log `:keys keys`, which is always the normalized vector, so a
single key and a collection report the same shape.
The tests pin the line: the keys, the hint, the error level, the logger
and the timeout as cause, plus the `nil` the caller gets.
The prod-infra memory now notes that client-side redis timeouts mean the
JVM is paused, not that the server is slow.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Fix render_to_pdf call in the ToUnicode test
The test passed a scale argument that render_to_pdf does not take, so
the render-wasm test build failed to compile.
* 🐛 Draw masked group inner shadows in PDF and multi-element export
The CPU renderer behind PDF and multi-element raster export drew a
masked group's drop shadow but never its inner shadow, so the masked
content came out flat. Wrap the masked content in a layer that draws
the inner shadows over it, as the GPU single export does.
* 🐛 Export every visible inner shadow of a masked group
The workspace skips shadows too small to see at the current zoom, to
render faster. Export has no such need, so draw every visible inner
shadow instead of filtering by scale.
Noto Sans Symbols lacks most arrows and math operators. Fall back to
Noto Sans Math for them, and to Noto Sans for letterlike symbols.
Closes#12469
AI-assisted-by: claude-opus-5-5
WASM find_guide_at only checked distance on the guide axis, so board-bound
guides stayed interactive outside their board. Respect frame_range in
GuidePool hit testing so interaction matches the drawn solid segment and
the SVG hit area.
Closes#12322
Skia maps ligatures and other substituted glyphs to U+0000 in the PDF
ToUnicode maps. Rebuild those maps from the laid-out paragraphs and
append them as an incremental update.
Closes#12393
The exception page compared the screen query param against literals
that never occur ("view", "dashboard"), so viewer and dashboard urls
of inaccessible files and teams fell through to the plain not-found
page instead of the request access modal. Classify the param with a
tested helper that recognizes "viewer" and every "dashboard-*" route.
Closes#11224
AI-assisted-by: muse-spark-1.3-contributor
Add the missing 2.18.4 entries for the closed storage GC,
version quota and file_change revision issues, with their
fix PRs referenced inline.
AI-assisted-by: muse-spark-1.3-contributor
Drop the legacy TOAST STORAGE EXTERNAL overrides set when PostgreSQL
ran on ZFS with transparent compression. Migration 0156 restores the
type default (SET STORAGE DEFAULT) on 62 columns across 21 tables;
existing TOAST data keeps its strategy until rewritten.
Raise the documented PostgreSQL floor to >=16, required by the new
SET STORAGE DEFAULT syntax.
AI-assisted-by: muse-spark-1.3-contributor
Inner shadows were joined into extrect via the drop-shadow filter, so a
Y offset grew the PNG/SVG export surface. Only drop
shadows expand bounds, matching classic export. Closes#12386.
Comment out the media-storage-bytes-per-team check in
upload-file-media-object. The SUM over storage_object takes
3-12s per upload on prod (3.5TB DB, ~34 slow runs/hour)
and blocks uploads. Keep the SQL and quote definition
for re-enable with a cheaper implementation.
Update the upload quota test to document the disabled
state until the new implementation lands.
AI-assisted-by: muse-spark-1.3-contributor
A modified shape is a cached copy, and edits through get_mut did not
reset it. After Enter the preview stops sending modifiers while the size
holds, so the canvas kept painting the old text.
AI-assisted-by: claude-opus-5-5
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
* 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>
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>
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
* 🐛 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.
* 🐛 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
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
* ✨ 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
* 🐛 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
* 🐛 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
* 🐛 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>
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
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
* 🚑 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
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