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
* 🐛 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
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
* 🚑 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
`.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
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
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
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
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
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.
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.
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.
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.
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.
* 🐛 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
* 🐛 Ensure normalized names in components and variants
We have a rule of that shape names that include / separator should have
it normalized, with one space before and one after. But this rule is
being enforced only in the UI and the plugins API. There may be other
ways of creating shapes that does not follow it.
For normal shapes this is not really a problem. But for main instances
it may cause that the name of the shape does not match the joined
path+name of the component, and this may generate some problems.
We have decided to add a migration to fix this in all main instances and
variant containers and enforce it in the future with integrity
validations (normal shapes may be left as is).
* 🐛 Fix bad variant-id in the component and the main instance
* 🔧 Fix some issues detected in PR review
* 🐛 Add nil guard
The dashboard required app.common.exceptions without ever using it,
and the plugin shape namespace still referred to
token-attr-plugin->token-attr after the per-side stroke width work
replaced its only use with expand-token-attrs. Both are leftovers of
earlier refactors; dropping them leaves clj-kondo with no warnings.
AI-assisted-by: deepseek-v4.1-flash
The libraries modal now opens on the "This file" tab, so the visual test
timed out looking for the shared library. Open the "Libraries" tab first,
as the functional test does, mock the file with the library linked, and
check the "Added" label instead of the old "File library" text.
AI-assisted-by: claude-opus-5-5
* ♻️ Use a DS icon button for flatten in the boolean options
Replace the raw flatten button and its deprecated icon with icon-button*
and the DS boolean-flatten icon. The title text moves to aria-label, so
the DS tooltip shows it. The grid column goes on the tooltip wrapper
through tooltip-class.
Add a visual test that selects two shapes so the boolean options show.
AI-assisted-by: claude-opus-5-5
* ♻️ Remove deprecated button styles from the boolean options
The flatten button is a DS icon button now, so its placeholders, sizes
and icon colors go away; only the grid column stays, on the tooltip
wrapper. Use block-size and drop the refactor/ import.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS buttons for more colors in the selected colors
Replace the three raw "more colors" buttons (library colors, colors and
color tokens) with the DS secondary button.
Add a visual test that selects a board with more than three colors so
the button shows.
AI-assisted-by: claude-opus-5-5
* ♻️ Move selected colors styles to DS tokens
The "more colors" buttons are DS buttons now, so their placeholder and
typography mixin go away; the class only centers the label, since the
column stretches the button. Delete the unused .add-fill rule, move
spacing to DS tokens and logical properties, and drop the refactor/
import.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS icon buttons in the align options
Replace the eight raw align and distribute buttons with icon-button*
and DS icons. The title text moves to aria-label, so the DS tooltip
shows the same text and shortcut. Disabled distribute buttons now use
the DS disabled color.
Add a visual test that selects two shapes so the align options show.
AI-assisted-by: claude-opus-5-5
* ♻️ Remove deprecated button styles from the align options
The align buttons are DS icon buttons now, so the .align-button rules
and their placeholders go away. Move the row height to a DS size token
and drop the refactor/ import.
AI-assisted-by: claude-opus-5-5
* ✨ Count the application logs as a metric, by logger and level
The error panels had no metric to read: they counted log lines in Loki,
because the backend published nothing about the errors of the application
(the only thing alive in Prometheus was the http listener error count,
which measures another thing). Now there is
`penpot_log_events_total{logger, level}`.
The counter comes from a new component, `app.loggers.metrics/reporter`,
which hooks a watch on the log record like the database and mattermost
reporters do, but without a thread or a channel: the increment is in
memory, so it runs in the watcher itself. Only `warn`, `error` and `fatal`
are counted, and only the records that go through `app.common.logging`:
the lines a library writes straight to log4j2 are not counted (one in a
thousand today).
The body of the watcher is wrapped in a `try/catch` that swallows the
error on purpose: the watcher fires from the `swap!` of the log record,
inside the logging path, so an exception there would reach whoever was
logging and would take the logging executor down with it. A test pins that
a failing publication does not propagate.
The suite removes the reporter (it would count the logs of the tests and
the recording of their `mtx/run!`), so a test of its own pins the wiring.
The memories carry the counter, its two limits and why the `try/catch` is
not noise; the grafana one also brings the notes of this afternoon about
the service account, the dashboard search and Cloudflare, which were
waiting for a commit.
AI-assisted-by: deepseek-v4.1-flash
* 📚 Address the review findings on the log-events metric
The review of the counter found that the reason written for the
`try/catch` was not the real one, so the memory and the comments say now
what happens if it is removed: with `::l/sync?` the exception reaches
whoever was logging, and on the async path it dies in the uncaught
handler of the logging executor -- outside the application's logging --
where the pool replaces the worker, so logging continues and that line is
lost. They also say that a publication failing every time is silent: the
counter stays flat and nothing is written about it.
The rest are small things the review asked for: the test of the `fatal`
level (it was in the counted set and nothing verified it), the HELP of the
metric naming the three levels, the require of the test without the unused
alias that clj-kondo flagged, and the note that the `level` is lowercase
here and uppercase in Loki.
The grafana memory loses what was said twice: the two pairs of bullets
about reading the API (the service account 403s and the search that lies)
are merged, the CLIMIT notes are one bullet instead of two, and the four
bullets about editing dashboards are grouped in their own section. Its
host name is gone as well, which the first line of that file forbids.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Decode job results with the schema of the job-def
A job result is stored as plain JSON, so a uuid comes back as the text
it is on disk and a uuid used as a map key comes back as a keyword. The
import resolution is a map keyed by file id, and the wizard lost that
identity: choosing a library in the link step created no link at all,
with no error and with the modal closing as if it had worked.
A job-def can now declare a result schema and its precompiled decoder,
the pair the params already have, and the substrate applies them when the
row is read. A def that declares neither answers with the JSON the row
holds, as before; a row whose job-def is gone is read untouched instead
of raising.
Relates to #12339
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Type the import result so its resolution keeps uuid keys
The resolution of a version 3 import is a map keyed by the new file id,
and the result column is plain JSON: without a schema of its own every
key came back as a keyword while the :id of the entry came back as text,
so the dashboard wizard could not match the file it was linking and
created no library link at all.
Declare the shape of the resolution (files, done and pending entries and
their candidates) and give the job-def its result schema and decoder.
The check before the result is stored keeps validating the same schema.
Closes#12339
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Take the imported file id from the entry, not the map key
The wizard looked a file up in the selection with the key of the
resolution map while it had indexed the selection by the :id of the
entry. The key is keywordized on its way through the job result, so the
lookup missed and the confirm button created no library link at all,
with no error and with the modal closing as if it had worked.
Take the id from the entry, the source the rest of the wizard already
uses, and move the decision to a pure function covered by tests of both
shapes of the resolution.
Relates to #12339
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Test that an import result keeps its ids as uuids
The regression this branch fixes is only visible through the read path
of the substrate: the resolution of an import is a map keyed by the new
file id, and a test that works with the value in memory never sees the
keys coming back as keywords from the plain JSON column.
Import a package whose library needs a replacement chosen by the user,
read the result with `jobs/get-job` as the client does, and check the
keys, the file ids and the ids of the candidates. Without the result
decoder of the import job-def the test answers with text ids.
Relates to #12339
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* 📚 Document the result schema of a job-def
The memories and the docstring of the substrate said a job result has no
schema of its own and comes back as the JSON the row holds. A job-def can
now declare its result schema and decoder, and the substrate applies them
when the row is read.
Keep the rule next to it: a result map keyed by uuid needs its schema
declared, or every key comes back as a keyword, and the link-later step
of the import wizard takes the file id from the entry and never from the
key of the resolution map.
Relates to #12339
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* 📚 Fix the documented contract of a job result
The docstring of get-job said a result has no schema of its own and comes
back as the JSON the row holds, which is what this branch changes: the
result is decoded with the schema its job-def declares, and a result that
does not conform never reaches the column because the write validates it.
Say the same in the management command that stores the result of an
external worker, the other writer of the column.
Relates to #12339
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Check the result of a job against its schema before storing it
The result of a job crosses a plain JSON column, so a uuid comes back as
text and a uuid used as a map key as a keyword. The job-def now declares
the schema of its result and the substrate compiles the decoder from it,
which is what the read path uses to get the types back.
The same schema is the contract of the write path: complete refuses a
result that does not conform, with the malli explanation, so nothing
invalid reaches the column, and the answer is the same for the runner and
for the external worker that completes a job through the management RPC.
A job that is no longer completable is skipped without checking, and the
read path decodes only: a row written by another version stays readable.
Relates to #12339
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Check the result of an import inside its transaction
The check ran after the transaction of the import had committed, so a
result the schema does not accept failed the job with the files already
created in the destination project: a half imported project and an error.
The core decides whether the whole import is kept or rolled back, so the
check belongs inside its transaction, which is what turns a contract
violation into a rollback.
The substrate checks the result again when the job is completed, which is
the only net for a job that does not validate its own result.
Relates to #12339
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Cover the edges of an import result read by the client
The shape of a result with no libraries to resolve, and the one of a
version 1 import, is the most common in production and neither was covered
by the tests that read a result the way the client does.
Cover both through jobs/get-job, and the case of a registry that no longer
knows the kind of the row: it answers with the JSON of the column,
keywordized keys and uuid as text, instead of raising. Say in the two tests
that depend on the decoder what implementation without it makes them fail.
Relates to #12339
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* 📚 Fix the prose and the fixtures of the result contract
The result schema of a job-def is what the read decodes with and what the
write checks: say that in the memories, with the rule that any uuid of a
result needs it, whether a value or a map key.
The motive of the wizard helper was a fact this branch makes false: what a
key of the resolution map holds depends on the result schema of the job-def
that wrote it, so the rule is to read the id from the entry. Same
correction in its memory and in the test that pins the shape.
The candidates of a pending entry match by slugified name, not by name, and
every test row now states the kind of its job, because the name is what
resolves its job-def when the result is read or checked.
Relates to #12339
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* ♻️ Keep the wizard link helper private and cover its shape
The helper that decides which links the wizard creates belongs to its
namespace, as its neighbours do: reach it from the test with @#', the
pattern the frontend tests already use for a private helper.
Cover the shapes it had no test for: a file with two libraries pending
resolution, a file whose libraries were auto-linked, and a selection where
only one of two entries was resolved.
Relates to #12339
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* ♻️ Share one job-row helper across the job tests
Three test namespaces had their own copy of the same insert, differing only
in the kind of the job and in whether the row holds the resource it
consumes. Move it to the test helpers as create-job*, with the kind of the
job as its first argument: it is what resolves the job-def of the row when
its result is read or checked, so every test states it.
The bang of the name goes away with it: the design rules keep it for the
mutating API of app.db, and this is a fixture of the tests. The three
namespaces lose the requires that only their removed copy used.
Relates to #12339
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
---------
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Handle tokens libraries when exporting .penpot files
Exporting a file that takes its tokens from an external library left
the :tokens-source of the file pointing at a library that is not in the
export, and the tokens lib of that library was not exported.
With :detach-libraries, unset the :tokens-source and the tokens status
in detach-external-references. The file is exported without any link to
the library, and the shapes keep their applied tokens.
With :merge-libraries, add cfo/absorb-tokens-lib, which copies the tokens
lib of a library into a file and unsets its :tokens-source, so the file
becomes its own tokens source. The tokens status of the file is kept; if
it has none, the one of the library is copied. If the library has no
tokens lib, only the source is unset. embed-assets calls it for the
library that is the tokens source of the file, and ignores the other
linked libraries.
Add unit tests for absorb-tokens-lib, and binfile tests that export a
file with tokens from an external library with both modes, checking the
exported files, the tokens lib, the tokens source and the applied
tokens.
Also reformat the export-type docstring in binfile v3 and remove a
leftover debug prn in the export dialog.
AI-assisted-by: claude-sonnet-5-5
* 🐛 Preserve active token sets when importing linked libraries
Cache each decoded tokens library for the duration of an import so files
and their tokens source resolve active sets against the same generated IDs.
Add a round-trip regression test for exports that include linked libraries.
* 🐛 Preserve tokens when deleting or unpublishing libraries
Absorb the tokens library into files that use it as their tokens source.
Keep referencing file IDs in the deletion task before removing relations.
Embed tokens from deleted, unlinked sources during export, and clear stale
source and status data when the library no longer exists. Add regression
tests for deletion, unpublishing, and exports with unavailable libraries.
---------
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
With the mod key, resizing a shape with an image fill crops it: the
shape is a window over a picture that keeps its size and position.
When the dragged edge crossed the opposite one, the crop mode turned
off and a normal resize with a negative scale flipped the shape. The
image transform is relative to the shape, so the picture was mirrored
and shrank a bit more on every crossing until it disappeared.
Keep the crop mode when the edge crosses: the window moves to the
other side of the fixed edge without flipping, the part outside the
picture shows empty, and moving the window back shows the picture
again. The crop math lives in app.common.geom.image-crop and replaces
the image transform code that was duplicated for fills and fill-image.
Closes#12349
AI-assisted-by: claude-opus-5-5
Port staging c74d415 (#12252) to main, adapted to this branch.
The :export-shapes modal event carried the full :exports vector,
each export embedding its shape, which could reach megabytes and
wedge the audit archive queue past the proxy body limit. Build a
compact body instead: totals, distinct shapes and per-type counts,
plus the page name. Other modals keep their props.
Move the modal :origin prop to ::ev/origin so make-proto-event
records it as :event-origin; the UI still reads the plain prop.
Bound exception messages at the source: get-hint clamps to
max-message-length (1000 chars) and the CLJS format-throwable
clamps the trace message line and honors :trace-length. The
Context Hint header in generate-report prints get-hint instead
of the raw hint. Also fix first-line on the JVM, where
cuerdas/index-of returns nil and (pos? nil) throws.
Adaptations to main: skip audit-log.md (absent here), skip the
generate-report huge-message test (staging async helpers are
absent), and fit the errors.cljs hunk to main's generate-report.
AI-assisted-by: muse-spark-1.3-contributor