Wrap every id prop in the comp-processors warn logs
with str so the log shows plain text instead of
the #uuid reader form. Also use :hint instead of
:msg and lowercase text, as the rest of the system
does. No logic changes, logging only.
AI-assisted-by: muse-spark-1.3
* 🐛 Show the size badge on paths and straight lines
The badge was hidden when the shape was smaller than the badge in
either side. Flat paths have a zero width or height, so they never
showed it, and straight two-point paths hid it on purpose.
Paths now only compare their longest side with the badge width. A
straight line compares its length, and the badge runs along the
line, rotated with it and kept upright. Near the bottom of the
viewport the badge moves to the other side of the line.
The badge now reads the size from the shape :selrect, like the
measures inputs, so a rotated path shows the same values in both
places. The three badge branches share a single render block.
AI-assisted-by: claude-opus-5-5
* ♻️ Extract the size badge layout and add tests for it
Move the badge geometry out of selection-size-badge* into the pure
size-badge-layout function, so it can be tested without rendering.
The component now only picks the color and draws the result.
Add unit tests for rects, flat paths and straight lines: when the
badge hides, where it sits, how it rotates and how it moves when it
reaches the bottom of the viewport. Keep the line rotation between
-90 and 90 degrees instead of returning 360 for lines drawn leftward.
The e2e test still expected straight lines to hide the badge. It now
checks that the badge shows and follows the line angle.
AI-assisted-by: claude-opus-5-5
* ✨ Show the length of straight lines in the size badge
A straight two-point path is measured by its length, not by the box
around it, so its size badge now shows the length with two decimals at
most instead of width x height. Other shapes and paths keep width x
height.
The shorter text makes the badge narrower, so it also shows on shorter
lines; the rule is the same, the badge needs the line to be at least as
long as the badge.
Relates to #12064
AI-assisted-by: claude-opus-5-5
* 🐛 Hide the path size badge when the path is lower than it
Paths other than a straight line only hid the size badge when their
longest side was narrower than it, so a wide but low path, or a narrow
but tall one, still showed it. Flat paths no longer need that special
case, since straight lines get their own length badge. Use the same
rule as any other shape: hide the badge when either the width or the
height of the path is smaller than the badge.
AI-assisted-by: claude-opus-5-5
The toggle flip default was inverted: with an empty selection the
first toggle left it empty instead of adding the change, and an excluded
change could never be deselected, so the row checkbox was a no-op in
both directions; the group paths that pass include? are unchanged. The
refetch test deferred body never called inner-done, so with-mocks
neither restored the mocks nor completed the test, and the whole suite
hung on it; that is why the suite could not finish.
AI-assisted-by: glm-5.3-flash
Signed-off-by: Alvaro Tejero Cantero <alvorithm@teje.ro>
Check a milestone against the Main project board with
scripts/project-anomalies.py (check writes
tmp/<MILESTONE>-ANOMALIES.md): open issues with merged PRs,
milestone mismatches either way, needs triage labels and
unassigned non-community issues. Outsiders resolve in bulk;
only a missing milestone auto-fixes, on confirmation.
Teach scripts/gh.py batched issue lookup (issue subcommand),
milestone on prs output, plus assignees and projects fields,
and retry transient HTTP 504s.
AI-assisted-by: muse-spark-1.3
Move the inline programs out of the update-changelog skill into
scripts/changelog.py (check-merged, cross-ref, report) and shrink
the skill to the workflow alone.
Teach scripts/gh.py to retry transient HTTP 504s, resolve lookups
in batches of 50 (new issue subcommand, milestone on prs output),
and report 💥 entries without the breaking change label.
Add the nine missing 2.17.0 changelog entries found while
trying the new flow.
AI-assisted-by: muse-spark-1.3
In list view the height of each component row depended on its
thumbnail, which renders deferred. When the Assets tab mounted again,
rows were shorter until the thumbnails appeared, so the restored
scrollTop landed several rows further down and scroll anchoring kept
it there. Reserve the thumbnail height in the row grid so rows keep
their size from the first render.
Closes#11971
AI-assisted-by: claude-opus-5-5
The branch compare panel now offers per-change selection, grouped by
the component each shape belongs to. Every summary entry carries a
stable change key (kind/page/id, the design-branches PoC format), and
the panel's checkboxes key on it.
merge-file-branch and update-branch-from-main take the deselected keys
as an excluded set. An excluded change is dropped on the user's word:
a conflicting entity resolves to the target side (the recorded choice,
per the no-silent-winner decision), and an excluded addition cascades
to its added descendants, so no emitted change references a parent the
merge does not create. A merge whose every change is excluded refuses
with :nothing-to-merge.
The update squash needs no filter: whatever excluded main work is
missing from the branch lands in the squashed net as a branch-side
delta, so the branch state still replays from its repositioned base.
AI-assisted-by: glm-5.3-flash
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Adding a flex or grid layout to a board with children derives the
paddings from the distance between the board and its children. A child
that overflows the board, or floating point noise after "Resize board
to fit content", gave negative paddings. The shape schema rejects them
since paddings must be non-negative, so update-file failed and autosave
stopped. Clamp the derived paddings to 0, as the derived gaps already
are.
Closes#12107
AI-assisted-by: claude-opus-5-5
The tokens list container stays mounted when another token set is
selected; only the restore key changes. With no saved position for the
new set, use-restore-scroll did nothing, so the list kept the scroll
position of the previous set. Scroll to the top when nothing is saved
for the key. Freshly mounted panels are already at the top, so Layers
and Assets do not change.
Closes#11973
AI-assisted-by: claude-opus-5-5
The comparison pairs the branch's media rows with main's through the
content identity heuristic (media-pairs), which approximates the
pairing from the rows present on both files. The writes that actually
establish the pairing know it exactly, so now they record it and the
comparison reads the record.
Every file_branch_change row stamps media_id_map jsonb: the media id
map the save or the update from main that wrote the row applied, as
applied ({old-id -> new-id}, the branch's own row is the new one). A
save stamps the remap its media fix-up applied, filtered to the copies
whose source row the branch's source file owns; the squash row of an
update from main stamps the map applied to main's data before the
squash (the pairs plus the fresh copies).
files_branch.clj::branch-media-pairs unions the inverted stamps when
the log is fully stamped and falls back to the heuristic when any row
carries none (rows written before the column existed), so old logs
keep working and the heuristic never runs for stamped ones. Every
former media-pairs consumer goes through it: the comparison
(branch-id-map), both base normalizations, the merge and the update.
Signed-off-by: Alvaro Tejero Cantero <alvorithm@teje.ro>
AI-assisted-by: opus-5-5
* 🐛 Sanitize smuggled SVG on binfile import
Route imported SVG storage objects through the SVG sanitizer on
all three binfile paths (v3, v1, v2), closing the second bypass
of GHSA-ffhp-m958-qxvr. Integrity checks still run on the raw
bundle bytes first; only the persisted copy is the sanitized one.
Adds a shared sanitize-imported-svg helper with unit tests and a
v3 export-tamper-import test proving smuggled scripts no longer
survive the import.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Normalize SVG content-type on binfile import
Close the residual GHSA-ffhp-m958-qxvr bypass where a crafted
content-type spelling (uppercase, parameters) skipped the import
sanitizer. Detection now uses a shared case-insensitive predicate,
stored values are canonicalized, and the v3 re-read honors the
import size limit.
Extends the tamper tests to obfuscated spellings and adds
exhaustive v1 coverage, including the tempfile branch.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Gate binfile content-type on known storage types
Define the complete set of storage content-types in
app.common.media and enforce it on the binfile import schema, so
unknown types fail closed instead of passing through. Detection
keeps a single normalization at the boundary with an exact
predicate, and the string helpers use cuerdas.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Keep only produced types in storage-object-types
Drop apng, avif, penpot and plain text from the set: no flow
stores them, they only exist in the extension mapping table.
Every member must have a producing code path; unproduced types
stay out until some flow actually stores them.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Reject unknown content-type on v1 binfile import
Enforce the storage content-type allowlist on the v1 import
path, like v3 already does via schema. Bundles declaring types
outside the set, or none at all, fail closed with the same
media-type-not-allowed error as uploads. The sanitize branch is
explicit: SVG bytes are sanitized, anything else passes through.
AI-assisted-by: muse-spark-1.3-contributor
Renaming a token with "Remap tokens" grouped the references by container
and wrote the union of their attributes onto every shape in it. A shape
with a stroke width token on one side got the token on all four sides
when another shape in the same container had it on every side.
Group the references by shape, so each shape is only renamed on the
attributes it already had the token on.
Closes#12075
AI-assisted-by: claude-opus-5-5
Since stroke widths can be set per side, stroke width tokens are stored
in the four per-side attributes, not in :stroke-width. The inspect stroke
panel still read :stroke-width for Border width and had no token lookup
for the per-side widths, so it showed the raw value instead of the token.
Read the token of each side for the per-side widths, and the token shared
by every side for Border width. Files that are not migrated yet keep the
token in :stroke-width, so Border width still falls back to it.
Closes#12029
AI-assisted-by: claude-opus-5-5
* 🐛 Show the top stroke width in the old render
With the old render, the stroke width input showed "Mixed" when the four
sides had different widths or tokens. The old render only draws the top
side, and the input must show it as the global stroke width.
Move the input value and token logic into two pure functions. When per
side strokes are disabled, both return the top side: its width and its
token, or no token when the top has none.
Closes#12063
AI-assisted-by: claude-opus-5-5
* 🐛 Add e2e tests for the stroke width input in both renders
Cover the global stroke width input when the four sides differ. With the
new render it shows Mixed. With the old render it shows the top side:
its width, or its token when the top has one.
The old render cannot create per side data from the UI, and switching
the render reloads the page and drops the test state. The tests patch
the existing stroke token fixture instead, appending the per side fields
to a rectangle and a board.
Relates to #12063
AI-assisted-by: claude-opus-5-5
The nested group wrapper only turned into a wrapping flex row when it
had a direct button child. Since the pills are wrapped in the DS
tooltip (#11900), the button is no longer a direct child, so pills in
nested groups were stacked one per row. Match pills as "any child that
is not a group" instead, so the rule no longer depends on the pill's
own markup.
Also drop the .token-pill rules of this module: CSS modules prefix
them with the token_tree scope, so they never matched the pill button.
Closes#12099
AI-assisted-by: claude-opus-5-5
When the import modal content moved to import-files-stage* (#9958), the
"error list or file rows" if became a when followed by rows that always
render. A non-.penpot file yields an analyze error entry with no name,
so the modal showed an empty error row below the error list. Render the
file rows only when the error list is not shown, as before.
Closes#12116
AI-assisted-by: claude-opus-5-5
develop replaced the legacy one-shot task system with app.jobs
(#11542). Two branching call sites still used the removed API, so the
backend no longer compiled on top of it:
- files-branch/delete-branch! schedules the branch file's
:delete-object job with jobs/submit, joined to the caller's
transaction through the cfg's ::db/conn, as files.clj does.
- files-pull-request/send-review-request-emails! queues the review
request with eml/send and ::eml/reuse-conn, as the comment emails
do, and now takes the transaction cfg instead of the connection.
The tests follow: the rollback test redefines eml/send, and the
file-gc scheduler test reads the scheduled work from the job table,
whose params are plain JSON.
AI-assisted-by: claude-opus-5-5
develop's translation cleanup (penpot/penpot#11853) makes every shortcut
definition carry a :label fn holding a literal (tr "shortcuts.<id>"),
and command-definitions-carry-static-labels pins it. :open-branches,
added on the branching fork, had no label and no English string, so the
shortcuts panel showed the raw key and the test failed by name on the
rebase.
The label reads "Branches", the name the main menu already gives the
same panel.
frontend tests: 1200 tests, 4580 assertions, 0 failures.
AI-assisted-by: claude-opus-5-5
The viewer test that develop carries, share-link-bundle-trims-linked-
libraries, pins the envelope keys a trimmed library may expose, and the
branching column :is-branch is not one of them: whether the source file
of a library is a branch is not something a share-link viewer may read.
The column exists only on this stack, so develop's test could not see
it and the collision appears only on the rebase.
trim-library-data drops it. A library a branch links is materialised,
so the column is false there anyway; dropping it is about the envelope,
not about hiding a value.
backend-tests.rpc-viewer-test: 11 tests, 95 assertions, 0 failures.
AI-assisted-by: claude-opus-5-5
Branches and databases from the preview's earlier shapes are not
supported, and such branches are recreated. The earlier shapes are the
copy model, where a branch file kept its own data payload, and the
tables registered as 0150-add-file-branch-table,
0152-add-file-branch-table and 0153-add-file-pull-request-tables. This
commit removes the code that existed only for them. A fresh database
ends with the same schema as before.
- 0155-add-file-branch-table.sql loses its header about the old
registrations, every IF NOT EXISTS, and the base_branch_revn upgrade
and backfill. CREATE TABLE file_branch already declares the column.
- 0156 and 0157 lose every IF NOT EXISTS.
- 0158-add-file-branch-change-data-version folds into 0157, and its
file and registration go. CREATE TABLE file_branch_change declares
data_version NOT NULL, because both writers of the op log, a branch
save and the squash of an update from main, stamp every row.
- need-migration-to? loses its nil case, which replayed an unstamped
op-log row at the base's version, and the branch derive loses the
comment that described it. Stamped rows replay as before.
- The tests for a re-run of the branch table migration and for the
replay of an unstamped row go, with the require the first one used.
A database that holds the preview's older shapes must drop its
branching tables and the file.is_branch column, and delete the
branching steps from its migrations table, before it boots this code.
A database that ran 0157 without 0158 lacks data_version and counts as
an older shape. A database that already ran 0155 to 0158 under these
names keeps working, because a migration runs once per name.
AI-assisted-by: claude-opus-5-5
develop moved token activation out of the tokens lib into the document's
:tokens-status (penpot/penpot#10293): ctob/get-active-theme-paths is
gone, :set-active-token-themes is no longer a change type, and a set is
activated by id in the status rather than through the hidden theme. The
branch merge still read active theme paths off the lib, which on the JVM
is a compile error in app.common.files.branch-merge and therefore stops
every backend namespace that requires it from loading.
The merge now reads both halves of activation off :tokens-status, as ids,
and keeps the two conflict kinds and resolution keys the UI already
offers (:active-themes, :active-sets). compute-changes settles each half
by the rule it used before and emits one :set-tokens-status, because the
change replaces the whole status. The hidden theme stays out of the
theme diff, as before.
The two activation tests now build the status with develop's own
cfo/activate-set and cfo/activate-theme, and a third pins that a
conflicting activation keeps main's status until a resolution takes the
branch's.
common-tests.files-branch-merge-test: 84 tests, 331 assertions, 0
failures.
AI-assisted-by: claude-opus-5-5
Depth gate: the compare reads the op log and applies the depth gate
before it derives the branch, the same order merge and update from
main use, so a log too deep to replay refuses before the derive runs.
The refusal names the materialise and the delete, the two remedies the
gates do not refuse, in the hint and in the notification copy.
Resolutions: the conflict answers of merge-file-branch and
update-branch-from-main carry the main revision the conflicts were
computed against, and the client sends it as expected-main-revn when
resolutions are applied before the dialog's diff fetch returns.
Libraries: absorbing skips branch files, so unpublishing or deleting a
shared library writes no payload on a branch and the branch keeps its
own link; a soft-deleted library resolves nothing there until the
branch's next update from main brings main's absorbed copies. The
absorbed write passes the file's features to update-file!, which the
hand-built params dropped, so the pointer-map fragments the absorption
regenerates are persisted and no payload references a fragment no
write produced.
Queries: the project file count, the team file count, the list of
files that use a library, and the socket-REPL team duplication skip
branch files.
Snapshots: the branch refusal lives in check-restorable!, which the
restore command calls before its backup snapshot and the two
socket-REPL restore helpers reach through restore!.
Migration 0155: the base_branch_revn backfill repositions only the
rows whose branch file carries a data payload, so a re-run changes
nothing on the branches the op-log model creates.
Tests: the branch merge tests run in JavaScript, which the common test
runner lists; new backend tests pin the gates, the absorption skip,
the query filters, the restore refusals, the migration re-run, and the
conflict answers' revision; a new frontend test pins the token the
senders carry with resolutions.
AI-assisted-by: mixed models
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Two frontend comments still cited a docket entry id that resolves only
on the machine the set lives on, which the reviewer this branch is
written for cannot follow. Each citation stood beside the behaviour it
records, so both sentences stand without it, as in the earlier commit
that dropped four such ids.
The change in data/workspace/branches.cljs was staged by hunk: the
working tree holds another session's uncommitted edits to that file.
AI-assisted-by: mixed models
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
The branch comments called the set of ids a document's references are
written in (main's file id and media rows, or the branch's) a frame.
Penpot's code uses frame for a board, through `:frame-id`, so a reader
could not tell which sense a comment meant. The comments, the
docstrings of `get-base-data`, `branch-base-data`, and `remap-changes`,
and one test name now say id space. Every board-sense use of frame is
unchanged.
AI-assisted-by: mixed models
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
The create pull request note said Try interactions plays the branch
"as it is at this moment", which reads as the live branch. The player
serves the pull request's pinned review snapshot, so an edit made
after the request shows there only once newer changes are published.
The note now says so.
AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
Four comments cited docket entry ids that resolve only on the machine
the set lives on, and the reviewer this branch is written for cannot
follow them. Each citation stood beside the number or the rule it
records, so the sentences stand without it.
AI-assisted-by: mixed models
The clearance fallback loses its unit on a zero length, and the comment
about the banner's published room takes the blank line the rule asks for
above it. Both come from the palette lift, and neither changes what the
bar renders.
AI-assisted-by: claude-opus-5-5
Merge engine: a same-parent reorder, an addition under a parent main
deleted, and a branch deleting a container main edited each lost work
silently. Each now follows a policy read from
backend/resources/app/branch-merge-policies.edn, re-read when the file
changes, with the defaults: merge the reorder, and raise a conflict on
the deleted parent or container. Added siblings keep the branch's order.
A :mov-page index counts the target's full page list, a cleared page
background reaches main, a per-attribute resolution can remove an
attribute, and a token set reorder survives a rename on the other side.
Conflict payloads name :main and :branch by document in both
directions. Reorder detection costs nothing measurable on a page of
20,201 objects.
Data versions: each op-log row records the data version it was written
at, the branch replays its log in segments and migrates the document
at each boundary, and the comparison base is migrated like main and the
branch, so an upgrade no longer shows as false conflicts.
Writers: a binfile import, an admin repair, file GC and a snapshot
restore refuse a branch file, whose payload nothing reads, and the
branch base pin cannot be renamed into a user snapshot. A branch save
runs the ordinary media fix-up and logs the rewritten references. An
update from main deletes the branch's copy of media main deleted and
squashes no phantom media changes.
Commands and UI: the pull-request commands publish their messages after
the commit, the listing takes one connection per comparison and shows a
warning instead of "in sync" when a comparison fails, materialize takes
:branch-file-id, and a dashboard fetch that never answers ends in an
error state instead of a spinner, with a refetch on reconnect.
backend/dev/branch_merge_cases.clj builds one example file per merge
policy case for demonstrating the alternatives.
Two models wrote this change, claude-opus-5-5 and mimo-v2.6-pro, and
the work switched between them, so the trailer reads mixed models.
AI-assisted-by: mixed models