The measures menu is memoized and only re-renders when one of the
compared values changes. :layout-item-absolute was not among them, so
switching a flex child between static and absolute kept the X/Y inputs
in their previous disabled/enabled state until another compared value
changed or the shape was selected again.
AI-assisted-by: claude-opus-5-5
Switching sidebar tabs unmounts the layers panel. The search text and
filters lived only in component state, so on return the still open
search bar was treated as a new search and cleared. Keep that state per
file in a session atom, as the assets panel already does.
Closing or clearing the search mounts the tree container again, but the
restore hook only ran on mount or page change, so the tree went back to
the top. Let use-restore-scroll take an active? flag so each list
restores its own position whenever it is shown again, and drop the
saved search position on close so a new search starts at the top.
Closes#11972
AI-assisted-by: claude-opus-5-5
* 🐛 Fix nested libraries vanishing from the libraries modal
The "This file" tab nests each linked library under a linked library
that uses it, taken from the team shared files. A library with such a
parent was removed from the top level and only drawn under its parent
if the parent was at the top level itself. So:
- In a chain of two or more levels (file -> A -> B -> C), C was lost.
- In a cycle (A uses B and B uses A), both had a parent, none was at
the top level, and both were lost.
The libraries showed while the shared files were loading, and vanished
when they arrived.
Build the list as a tree instead: direct libraries and libraries not
used by any other linked library are roots, nested libraries follow
their parent at any depth, and each library is added only once, even
when the dependencies form a cycle.
AI-assisted-by: claude-opus-5-5
* ♻️ Move the linked libraries tree to sort-linked-libraries
Extract the code that orders the linked libraries as a tree from the
file tab to a public function, so it can be tested on its own. Add
tests for a cycle with a direct root, a cycle with no root, a deep
chain and no dependencies yet.
AI-assisted-by: claude-opus-5-5
* ♻️ Use connected-to to find library parents when sorting
The caller already computes :connected-to for each linked library, the
reverse index of the dependencies map. sort-linked-libraries rebuilt
the same relation from dependencies. Read it from :connected-to
instead, so the function takes only the libraries and the component
memo no longer depends on dependencies.
Reword the cycle-without-root test to say it models the unknown direct
flag, and add tests for a diamond, a self-dependency, a cycle of three
without root, a parent that is not a linked library, and for no
library being lost or duplicated.
AI-assisted-by: claude-sonnet-5-5
---------
Co-authored-by: Andrés Moya <andres.moya@kaleidos.net>
* 🐛 Fix inner shadow covering the shape in the SVG renderer
The svg filter components moved to the `*` syntax (#9448), but
filter-entry* still built their props as a raw JS object with
kebab-case keys. Raw JS props skip the kebab to camel conversion, so
the components read nil for filterId and filterIn, and no filter
primitive got its result or in2. With the chain broken, an inner
shadow was blended over the empty flood instead of the shape, hiding
the fill (and a board's children) under the shadow color. Exports
using these filters broke the same way. Pass the props in camelCase.
Closes#11967
AI-assisted-by: claude-opus-5-5
* ♻️ Build filter entry props with mf/props
Use mf/props instead of a raw JS object so the props keep the
kebab-case keys used across the codebase. The macro converts them to
camelCase at compile time, so there is no runtime cost, and the
comment about the manual conversion is no longer needed.
AI-assisted-by: claude-opus-5-5
Template links (#?template=<url>) keep their params in a bare "#?"
fragment. Since routing moved to the query string, only "#/" legacy
hashes are translated, and the root route was an empty query string,
so the template param was dropped and the dashboard opened without the
import dialog. Route a "#?" fragment as the query token, and treat a
query without a screen param as the root, as before.
Logged out users also lost the template: get-teams requires a session
and its 401 ended the flow before the params were kept for after login.
Skip it for the anonymous profile. This part already failed on main.
Closes#12127
AI-assisted-by: claude-opus-5-5
Remove per-side stroke widths from both Inspect geometry panels so
Stroke is the only section that lists them. Keep dimensions, position,
corner radii, and transforms in Size and position.
Add rendered-component regressions for boards, rectangles, multiple
selection, uniform strokes, and CSS declarations.
Closes#12026
AI-assisted-by: GPT-6
Signed-off-by: Danial Shirali <danialshirali16@gmail.com>
Signed-off-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
* ✨ Run binfile import and export as durable jobs
Import and export of `.penpot` files become durable jobs: two commands
create them and answer at once with a job id the caller follows, while
a worker of the `binfile` queue does the work. Each job keeps its own
ledger (owner, params frozen at creation, result, progress history and
an expiry), and the legacy commands and their SSE keep working.
Why: both operations ran inside the request. A large import held it
open for minutes, progress only existed while the caller kept the
stream open, and a client that went away left work nobody recorded,
retried or could cancel. The unified `job` substrate already dispatched,
leased, retried and logged events, but it had no user-facing ledger.
How:
- The substrate gains the pieces a user-facing job needs: optional
job-def metadata (family, resource-role), an expiry on submit, the
owner in the runner context, and job resources in their own bucket,
written with the profile of the job and never deduplicated.
- One generic command per family: the caller names the job and sends its
business params, the registry checks the family, the job-def decodes
and validates, and permission and quota are checked first.
- Progress taps of the core become milestones of the job: a shared stage
vocabulary with self-contained counters, throttled heartbeats, and an
interrupt that stops the run inside its own transaction on cancel.
- Cancel reaches pending and running jobs from both modals, and closing
a modal mid-run cancels the job it was following.
- The export re-checks the read permission at run time and answers with
the package descriptor; the import re-checks the edition permission,
runs the core in a transaction so a cancellation rolls it back, and
releases the consumed package and its temporary copy.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Handle externally-cancelled binfile jobs end to end
The `cancelled` outcome of a job nobody followed no longer hangs its
screens: the data layers emit an explicit terminal message for it.
- The export emits `{:cancelled true}` per file and the modal paints a
neutral cancelled row instead of success; a message without an
artifact never reaches the download branch.
- The import closes every entry with a dedicated cancelled message
through the existing error rendering, so the wizard completes.
- Both flows are pinned by data-layer tests, including the red state
they replace.
Also in this change: the progress-contract comment of the management
API, early business-params validation in create-import-job (before
assembling the upload), a `release-resource` helper without fabricated
job ids, a note on the heartbeat write cost, and a memory cleanup.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Pre-translate the cancelled import message
The cancelled entry of the import wizard carried its message as a key
for the UI to translate, which needed a rehash marker comment. Like
its neighboring errors, it now translates eagerly in the data layer:
the literal call keeps rehash clean with no marker, and the tests
assert through the same call instead of the raw key.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Move cancelled strings under generic labels keys
The dashboard-specific cancelled key never shipped: before use it moves
under `labels.*` next to the generic strings both flows already use,
with Spanish translations following the established wording.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Add minor improvements
* ♻️ Rename export job command to create-export-binfile-job
Export now has one command per job type, the shape the :export-assets
command needs: create-export-binfile-job freezes exactly the
:export-binfile job, so :name no longer travels in the body and only
:params does. Import keeps the generic envelope.
submit-job reads the queue from the job-def metadata ::jobs/queue-name
(falling back to :binfile), read by the command and never by the
substrate, so a job-def can route its work to another queue without a
change in the command.
The rename reaches the frontend and the tests: the binfile export is
asked for under its own name, and the exporter service keeps
create-export-job.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Polish the import and export status rows
The status row of a file already carries what the file is doing, so the
generic "Uploading file…"/"Downloading file…" lines are gone: the
import wizard drops its own (the export one went with the jobs
refactor), along with the now-unused style.
In the export, every file is queued up front, even the ones whose job
is created only after the previous one is over, and a file stays queued
until its first milestone: a worker picking the job up no longer ends
the queue. The queued text is a step smaller than the milestone.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Show the cancelled export and fix the import error
The cancelled export was unreachable: mark-file-cancelled sets :loading
false and the label lived inside the loading branch, so a cancelled
file only painted a neutral notification. The terminal state now
carries the label, and the export flags lose the `?` suffix
(predicates keep it; a data prop is not a question).
The import wizard rendered (:error entry) through tr, but every
producer writes user-facing text (a hint or a translated label), never
a key: it renders the text as-is now, and the dynamic-key ignores and
the stale comment are gone.
The total-text-limit comment no longer references the 200MiB it
replaced; the value is 500MiB in develop already.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Reflect the real job params in the creation commands
The creation commands declared their params as an open empty map, so the
RPC contract and the generated docs said nothing about them. Each command
now imports the schema from the job namespace
(`export-binfile/schema:params`, `import-binfile/schema:create-params`),
and the RPC validates the business params before the command runs. The
job-def still owns the params and re-validates the assembled ones at run
time. The commands no longer decode the params again: the RPC did it.
Import joins export as one command per job type: `create-import-job`
becomes `create-import-binfile-job`, its body no longer carries `:name`
and the job-def is resolved by name like export's. Its creation schema
keeps the version optional, because the command reads it from the package
header when the caller does not say it, and fills the manifest metadata
kept for audit.
A version out of range is now refused by the schema (`:params-validation`)
instead of the command's own `:unsupported-version` check, which is gone.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Stop the test system from running job runners
The test system is main/system-config plus main/worker-config with a
few components dissoc'ed. The runners of default, webhook and cron were
listed by hand; the binfile runner added with the durable binfile jobs
was not, so a real penpot/job-runner/binfile/0 thread ran during the
whole suite.
That runner races the tests that drive the loop by hand (run-batch plus
run-worker-loop): it takes the job the test just dispatched and
completes it, and the test then reads a state it did not produce. That
is the reported flake of binfile-jobs-integration-test, whose middle
assertion waits for a runner of another queue to not take the job. It
only reproduced in CI: in the devenv PENPOT_TENANT makes the tenant
baked into main/worker-config differ from the one the test builds, so
the two used different queue keys locally.
Drop every [<profile> :app.worker/runner] by key shape instead of
listing the known queues, so a new queue cannot bring the race back.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Upload the package one chunk at a time and as a percentage
The upload milestone counts chunks, not units of work, so the row wrote
"Uploading the package 3/9" over a file the user had uploaded once: it
read as if nine packages were on their way. Write the stage as a
percentage of the file instead ("Uploading file… (33%)"), and make the
English text of the stage say file, not package.
The chunks also go one at a time now (concat-all instead of merge-all
with two in flight), so the percentage moves in a straight line instead
of jumping. upload-blob-chunked is the generic uploader, so media and
font uploads become sequential too.
AI-assisted-by: deepseek-v4.1-flash
* 🌐 Complete the Spanish catalogue
es.po was 25 keys short of en.po: the 15 that the durable binfile jobs
introduced (14 jobs.progress.stage.* and labels.queued) plus 10 gaps
that predate them (errors.connection-error, errors.save-retrying, the
find shortcuts, workspace.header.retrying,
workspace.tokens.stroke-width and the mcp session labels).
sync -l es then sorts the entries, syncs the #: comments from en and
drops the 7 dashboard.import.progress.* keys en no longer has. The
msgid sets of en and es are now identical (0 missing, 0 extra).
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Move the job status keys into the jobs namespace
The three status words the durable import and export rows render were
filed under labels.*, next to the generic labels bucket, while their
progress stages live under jobs.progress.stage.*: the strings of one
feature sat in two places.
Rename them so the whole job vocabulary is one namespace:
labels.queued -> jobs.queued
labels.export-cancelled -> jobs.export-cancelled
labels.import-cancelled -> jobs.import-cancelled
No other locale has these keys yet, so the rename costs no translation.
The convention (a key names the feature that renders it; labels.* holds
the generic reusable words) is written down in the frontend/translations
memory.
AI-assisted-by: deepseek-v4.1-flash
* 🔥 Remove the translation keys nothing references
Six keys of en.po (and of es.po, which now mirrors it) have no (tr ...)
call site left in frontend/src or common/src, and no literal reference
either:
labels.uploading-file a duplicate of jobs.progress.stage.upload
since the upload stage says "Uploading file…"
labels.downloading-file dead since the durable jobs replaced the old
import and export progress rows
inspect.attributes.stroke.alignment.center
inspect.attributes.stroke.alignment.inner
inspect.attributes.stroke.alignment.outer
workspace.toolbar.mcp-connect-here
The first two are leftovers of the durable binfile jobs, the other four
predate them. Both catalogues keep an identical msgid set afterwards.
AI-assisted-by: deepseek-v4.1-flash
Remove per-side stroke widths from both Inspect geometry panels so
Stroke is the only section that lists them. Keep dimensions, position,
corner radii, and transforms in Size and position.
Add rendered-component regressions for boards, rectangles, multiple
selection, uniform strokes, and CSS declarations.
Closes#12026
AI-assisted-by: GPT-6
Signed-off-by: Danial Shirali <danialshirali16@gmail.com>
Signed-off-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
* 🐛 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 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
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
* 🐛 Fix token display for multi-selected text layers
When multiple text layers share the same fill token,
the design panel showed the hex value instead of the
token name. type->token-attrs derived token keys from
type->editable-attrs, which returns empty for text
shapes in the fill group. Fall back to the group's
own attrs when editable-attrs is empty.
Closes#11924
AI-assisted-by: claude-opus-4-6
* 🐛 Take text token attrs from the group attrs
The :text read mode reads values from the group attrs, so its
token attrs now come from those attrs too, instead of falling
back when the editable attrs are empty. type->token-attrs is
restored to its original form.
Add regression tests for fill tokens on multiple selections of
text shapes, and of a rect mixed with a text.
AI-assisted-by: claude-opus-5-5
---------
Co-authored-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* ♻️ Derive per-side stroke widths from the side values
The per-stroke `:stroke-per-side` boolean only gated whether the
renderer looked at the four side widths, and the CSS generator used it
to decide whether to emit one `border-width` or four. Comparing the
sides is enough, so drop the attribute from the shape schema and from
the stroke attribute list.
The WASM property and upload bridges and `stroke-per-side-widths` now
derive the per-side widths from the values alone.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Declare per-side stroke width token attributes
Replace the single `:stroke-width` token attribute with
`:stroke-width-top`, `:stroke-width-right`, `:stroke-width-bottom`
and `:stroke-width-left`, add `per-side-stroke-width-keys`, and map
the new attributes to the strokes shape attribute and to the
dimensions token type.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Unapply only the token of the changed stroke side
A stroke change that reports a single per-side sub-attribute now
resolves to that side's token only. A plain `:stroke-width` change
still resolves to every side, and a change with no sub-attribute
resolves to all width keys plus the color.
Add a regression test that tokens on untouched sides survive a change
to another side.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add a predicate for per-side stroke shapes
Boards and rectangles support independent stroke widths per side.
Expose `per-side-stroke-shape?` so callers can gate the per-side UI,
and cover the supported and unsupported shape types with a test.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add stroke side width materialization helper
`materialize-stroke-side-widths` concretizes the four per-side width
keys from a stroke: edited sides take the new value, the others keep
their current width (0 when there is no stroke), and `:stroke-width`
mirrors the top side for legacy consumers.
This pulls the logic already duplicated in the token apply path into a
shared helper, ready for the direct-edit path.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Apply a stroke width token to every side
`update-stroke-width` now writes the four per-side width keys, both
when the shape already has a stroke and when it gets a new default
one, so the applied-token bookkeeping matches the stroke attributes.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Apply a stroke width token to a single side
Add `update-stroke-width-side`, which changes only the sides named in
`attributes` on the first stroke of each shape. The remaining sides
keep their current width (0 when the shape had no stroke) and all side
keys are materialized through the shared helper, so consumers never
fall back to `:stroke-width`.
Route the per-side token keys to the new function and update the
apply, remap and component tests.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Complete a partially applied per-side token on toggle
When explicit attributes come from an input or a plugin call, toggle
the token off only if it already covers every target attribute on every
selected shape. A partial per-side application is completed instead of
removed. The token pill keeps the previous any-attribute behavior.
Add tests for both the completion and the full removal.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Offer per-side stroke width actions in the token menu
Boards and rectangles get a stroke width submenu with an all-sides
action and one action per side; other shapes keep the single global
action. The global action targets every per-side attribute so the
design tab keeps showing the token on each side.
Add the pill labels for the new attributes, the menu test, and the
`workspace.tokens.stroke-width` string.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Persist the per-side stroke preference
Add `:stroke-per-side` to the user profile props schema and expose it
through a derived `stroke-per-side` ref. The design tab will read the
preference from here instead of a per-stroke attribute.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add per-side stroke width helpers to the stroke menu
Add `stroke-width-all-attrs` and `per-side-stroke-available?`, which
checks the feature flag and that a single board or rectangle, or a
uniform multi-selection of them, is selected. Use it instead of the
inline shape-type check and drop the old per-side toggle handler.
Cover both helpers with a test.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add per-side stroke width token inputs to the design tab
Turn the four side width fields into token inputs with detach actions
and a `:multiple` mixed value when the sides differ. The per-side
toggle now reads the persisted profile preference instead of a
per-stroke attribute, so previous per-side edits survive.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Materialize stroke sides on direct width edit
The stroke menu per-side handler only wrote the edited side key and,
for the top side, the global `:stroke-width`. A stroke holding just
`:stroke-width` made every consumer fall back to the global value, so
editing one side changed all the others.
Add a `change-stroke-side-width` event that materializes the four side
keys through the shared helper and reports only the edited key as
changed, so tokens on untouched sides are not unapplied. Route the menu
handler through it.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Make stroke width fields non nullable
Drop `:nillable` from the global and per-side stroke width inputs and
use `:multiple` for the mixed state, so an empty field no longer
represents a null width.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Fix the numeric-input props schema key
The schema declared `:applied-token`, but the component body and every
caller use `:applied-token-name`, so the prop was never validated.
Rename the schema entry to match.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add token-disabled support to the numeric input
The design-system numeric input accepts `:token-disabled` and
`:token-tooltip`; the token button is disabled and shows the reason.
Scope the disabled input style to `input:disabled` so a disabled token
button no longer dims the whole field. Pass both props through the
token wrapper.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Disable token controls below the first fill or stroke
Design tokens only apply to the first fill or stroke of a shape. Add
`tokens-allowed-position?` and mark the fill and stroke lists with
`tokens-first-only`, so later entries disable their token controls and
explain why. The colorpicker opens on the direct color tab and disables
the token tab for those rows.
Cover the helper with a test and add the new translation.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Refactor colorpicker style switcher to DS radio buttons
Replace the legacy `components/radio-buttons` markup in the colorpicker
with the design system `radio-buttons*`, using its declarative options
API. Switching between direct color and token mode now passes string
values, as the DS component expects.
The previous keyword values broke the round trip back to color mode:
the DOM stringifies keywords with a leading colon, so the value never
matched `:direct-color`. Using plain strings keeps the conversion
clean.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add playwright tests
* ✨ Scope per-side stroke controls to each stroke
Give every stroke row its own expanded state instead of sharing one
profile-wide preference. The state lives in `:workspace-local`, keyed by
`[ids index]`, so it survives selecting another shape and coming back but
resets on reload.
Remove the `:stroke-per-side` profile prop and its ref. The ref now derives
from `:workspace-local`.
Update the Playwright spec to expand the controls per stroke through the
toggle, and assert that strokes toggle independently, that the state resets
on reload, and that it survives switching shapes.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Keep stroke tokens when editing or removing later strokes
The token unapply logic decided which tokens to clear from the shape
using only the changed sub-attributes, without knowing which stroke was
edited. Since stroke tokens only live on the first stroke, editing or
removing a later stroke cleared the first stroke's tokens.
Add a `:changed-item-index` option to `generate-update-shapes` and skip
unapplying fill/stroke tokens when the changed item is not the first.
The stroke color, attrs, side-width and remove events now report the
index they touch.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Ignore token shortcuts when tokens are disabled for input
The numeric input opened the token dropdown on `{` regardless of
`token-disabled?`, so inputs that cannot hold tokens (for example,
strokes after the first one) still opened it, and typing `{token}` plus
`}` could apply a token there.
Extract the key handling into `token-shortcut`, which returns nil when
tokens are disabled, and use it for both `{` and `}`.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Gate per-side stroke tokens on the WASM renderer
The token context menu offered per-side stroke width actions whenever
the feature flag was on and the shape was a board or rectangle, without
checking the renderer. The classic renderer only draws the single
`:stroke-width`, so applying a per-side token there wrote inert data,
the token pill reported it, and the stroke changed appearance when the
WASM renderer was later enabled.
Add `per-side-stroke-enabled?` (flag + WASM renderer) and use it from
both the design tab and the token context menu. Thread the renderer
flag into the context menu through `:render-wasm`.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Keep first-stroke tokens when reordering later strokes
Fill and stroke tokens only ever live on the first item of the
collection. When a stroke update arrives without a changed item
index (for example reordering the second and third strokes), the
unapply logic assumed the first item had been edited and removed
every stroke token from the shape.
Compare the first item before and after the update instead: when
no item index is given, unapply only if the first item actually
changed. Reordering later strokes now leaves the first stroke and
its tokens untouched, while moving the first stroke away still
detaches them. Explicit item edits keep their previous behavior.
AI-assisted-by: deepseek-v4-flash
Drop 277 keys nothing references from en.po (verified against
frontend/src and common/src) and let sync propagate the
deletions to every locale. Clear all 10 fuzzy entries: fill
the 5 empty translations, keep the 4 valid ones, and drop the
duplicated max-quote-reached in favor of max-quota-reached
(the backend code stays, the UI maps it to the quota text).
Recover 22 used-but-missing keys with translations: the 19
shortcuts section/subsection labels plus connected-to,
pixel-grid-color and tokens.add-set. Make the rest
statically visible to rehash instead: :label fns on shortcut
commands, sections and subsections (one debug-only and one
colorpicker-local id exempt); case branches in place of
dm/str-built keys (export modal, text decoration and
transform, undo history with raw-key fallback); hoist
conditionals out of tr calls; pre-translate modal props and
role labels; replace the lone (i18n/tr ...) site with tr.
Turn static :error/code data into eager :error/fn calls in
the common schemas and the auth/password forms. Rename the
two keys containing spaces and point team leave at
max-quota-reached. Backend-driven keys stay dynamic by
design, declared with (tr ...) comments: the five
weak-password details, team and organization notifications.
Tooling: rehash also scans common/src and no longer treats
a missing -l as no locale; new clj-kondo tr-dynamic warning
flags non-literal tr args (lint scripts use --fail-level
error so it never fails CI); tr docstring states the
literal-only rule. Tests cover the shortcut label wiring,
the undo-history fallback and the :error/fn schemas.
Translations memory rewritten to match; es check word list
gains three entries.
Rebased onto develop: adopt the register field-error UX
(the weak-password declarations move onto the :options
code), keep develop's newer keys (connection-error,
account-locked, save-retrying, tokens-source strings) with
fresh references, and reword the shortcuts.cljs prose
comment so rehash does not invent a "literal" key.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix organization/team switcher issues from UX review
* 🐛 Let members leave an organization without SSO credentials
* 🐛 Fix style from previewed organization in the team switcher
* 🐛 Fix leave-organization modal test stubs
* 🐛 Preserve layers panel scroll position across tab switches
Fixes#7440. Switching between the Layers, Assets and Tokens tabs in
the workspace left sidebar unmounts the active panel component, causing
its scroll position to reset to the top on re-entry.
Add a module-level `scroll-positions` atom keyed by page-id. The
layers scroll handler now also saves the current scrollTop value into
the atom; a `mf/with-effect` on the page-id dep restores it whenever
the `layers-toolbox*` component mounts or the page changes.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* 🐛 Preserve sidebar scroll positions across tab switches
Replace the Layers-only global atom with a scroll store held in a
use-var in left-sidebar*, shared by the Layers, Assets and Tokens
panels through a new sidebar.scroll helper. Positions are keyed per
panel and page (or token set) and restore waits for list content to
settle, so deep positions in lazily rendered lists survive tab
switches.
Closes#7440.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Add e2e coverage for sidebar scroll preservation
Port the regression tests from closed PR #7544 for issue #7440,
adapted to the current ref-based implementation and fixtures:
async restore needs polled assertions, and setup uses the shared
tokens helpers. Also add data-scroll-container hooks to the Assets
and Tokens scroll containers so the specs can locate them.
AI-assisted-by: muse-spark-1.3-contributor
* 📎 Fix rebase issue
---------
Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
* 🐛 Fix Japanese IME Enter duplication in comment input
Comment keydown handler treated every Enter as a Penpot
line-break action, so confirming an IME composition
duplicated the text with an extra newline and a
zero-width space. Guard the whole custom keydown
processing while the event belongs to an active IME
composition, mirroring the v3 text-editor precedent.
Closes#11757
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Keep composing Escape from closing comment thread
The parent floating-thread keydown handler closed the
thread on every Escape, including one that cancels an
active IME composition. Apply the same composition
guard so composing Escape stays owned by the IME while
plain Escape still closes the thread.
Closes#11757
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Test comment IME guard through key-action resolver
The composition predicate test only verified the
predicate itself, so a guard moved to the wrong place
or a handler bypassing it would stay green. Resolve
comment and thread keydowns through a pure
resolve-comment-key-action seam and verify the
observable behavior: composing keys yield :ime-owned
with zero Penpot side effects while the same plain
keys keep their existing commands.
Closes#11757
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Test comment IME handlers through direct calls
The key-action resolver only verified a return value,
so handler wiring regressions would stay green, and it
read the mention snapshot before handle-select ran,
changing the existing ordering. Remove the resolver,
extract the two handler bodies as directly callable
fns with the original select-first ordering, and
assert the fired side effects instead.
Closes#11757
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
AI-assisted-by: muse-spark-1.3-contributor
* 📚 Remove text-editor v3 references from comment IME docs
The comment IME guard is specific to the comment editor, so the
docstrings no longer present it as following a v3 text-editor or
render-engine precedent. Reviewers read that wording as tying this
comment bug fix to unrelated subsystems.
Only docstring text changes; handler logic and test assertions are
untouched.
Closes#11757
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* 🐛 Review comments, and fix edge case
---------
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
* ♻️ Consolidate HIGHLIGHTS.md into CHANGES.md 🚀 section
Eliminate the redundant HIGHLIGHTS.md file and make CHANGES.md
the single source of truth for version highlights.
- Add 🚀 section for 2.15.0 (MCP server integration)
- Add 4 missing highlight entries to 2.17.0 🚀 section
- Rewrite frontend parser to extract from CHANGES.md 🚀
subsections instead of flat HIGHLIGHTS.md format
- Decouple parse-latest-released-version from highlights
extraction so it works independently of 🚀 content
- Conditionally render highlights section in modal when non-empty
- Rewrite tests for new parser behavior (11 tests, 21 assertions)
- Delete HIGHLIGHTS.md and remove .gitignore exception
- Add step 8b to update-changelog skill for proactively
proposing highlights during release workflows
- Add missing-highlights and missing-highlight-reference
anomaly types to the changelog anomaly report script
Closes#11530
AI-assisted-by: qwen3.7-plus
* ♻️ Use consistent string library and add multi-version test
Address code review findings:
- Use str/split (cuerdas) consistently in extract-rocket-items
instead of mixing cstr/split (clojure.string)
- Add parse-highlights-extracts-multiple-versions test to verify
the parser correctly extracts 🚀 items from multiple
versions in a single CHANGES.md body
AI-assisted-by: qwen3.7-plus
* ♻️ Scope 🚀 checks to X.Y.0 and split gaps from anomalies
Type C now only checks released X.Y.0 versions, since patches never carry 🚀 subsections by design. Type D requires both issue AND PR references with exact format, accepting multi-PR entries. C/D are reported as highlight gaps in their own section and no longer count toward the anomaly total. Key Principles and anomaly definitions updated to match. Addresses review comments on PR #11531.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Render markdown links and bold in check-updates highlights
The highlights modal showed raw markdown from CHANGES.md 🚀 lines (brackets and URLs). Add a pure parse-highlight-item parser for inline links and bold, render fragments with literal hiccup in the modal (links open in a new tab), and style links and strong elements. Non-http URLs and malformed markup degrade to plain text. Adds 12 unit tests.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Point full changelog link to main instead of staging
The view-changelog button in the check-updates modal linked to the staging branch. Point it to main, which holds the published changelog. Version detection still fetches from staging.
AI-assisted-by: muse-spark-1.3-contributor
* 🎉 Improve reset to default flow
* 🎉 Add diff modal
* 🐛 Fix measurement shortcuts
* 🎉 Separate open-sections for each tab
* 🐛 Align button link icon
* 🎉 Reset only works after press save
* 🐛 Fix little things
* 🎉 Make the import export row to be fixed
* ♻️ Fix CI
* 🐛 Cancel shortcut is not appearing on disabled tab
* 🐛 Fix tests
* 🐛 Fix loop on personalized
* 🐛 Fix show measurements shortcut
Combined fixes from PR #10656 (numeric-input redesign) and PR #10696
(global finite? guard) for issue #10638.
- Add (number? v) guard to cljs mth/finite? so strings are rejected
- Redesign numeric-input last-value* to store number, not formatted string
- Invalid-input fallback restores display without emitting on-change
- Esc now fully discards typed text (resets raw-value* + dirty flag)
- Token dedup by name instead of resolved value
- Defense-in-depth: d/parse-double at 4 padding/gap handlers
- Math tests (common), unit tests, Storybook play tests, Playwright E2E
AI-assisted-by: deepseek-v4-flash
Co-authored-by: Akshit Nassa <nassaakshit@gmail.com>
Co-authored-by: Ulises Millán <ulises.millanguerrero@gmail.com>
The handle transform composed rotation incorrectly, so handles blew out in size when an ellipse was rotated.
Fixes#10069
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The change-password form validated the existing password against the 8-character policy, locking out accounts whose current password predates it.
Closes#10626
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The Color tokens picker showed token sets in their raw definition
order (ascending precedence), so the lowest-precedence set appeared
first and the highest-precedence (last-defined, winning) set
appeared last. This is the opposite of what's useful: users care
most about which set is currently winning, so that one should be at
the top.
get-sets returns sets in definition order and the picker's
grouped-tokens-by-set pipeline (add-tokens-to-sets ->
filter-active-sets -> filter-non-empty-sets -> group-sets ->
combine-groups-with-resolved) preserves that order at every step, so
the picker just rendered get-sets' raw order. Reverse the set seq
once, before it enters the pipeline, so the highest-precedence set
renders first.
group-sets groups sets by parent path via group-by, which risked
restoring definition order within a subgroup independent of the
reversed input order. Added tests covering a flat set list, a
reversed subgroup (to confirm group-by does not silently re-sort
subgroup members), and a mixed flat/subgrouped list.
Closes#10552
Signed-off-by: Andrew Cunliffe <cunliffeandrewc@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The options stored in options-ref is a delay (lazy value). In
on-token-key-down, it was passed raw to next-focus-index without being
dereferenced first, causing count to be called on a JS object that does
not implement ICounted.
Fix: dereference the delay in on-token-key-down (matching the existing
pattern in on-key-down), and make next-focus-index itself also handle
delays defensively. Add unit tests covering the delay case.