The audit-log memory still described consumer 3 as the selective
storage-usage consumer, so the next session that read it would think
the generalist did not exist.
It now describes the generalist: id 3, a queue that takes the whole
stream, the :event-consumer flag, the reactions (what they are, who
owns them, and that the producer does not know them) and the flag
table. The team-storage reaction is the only one registered today, and
the cached path of the media-storage quota needs the consumer on: with
it off nobody marks the teams dirty and the quota would read a frozen
counter.
It also fixes what the rename left behind (the job names in the drain
paragraph and in the flags table) and records that update-file now
carries the id of the change row it created.
AI-assisted-by: minimax-m3.1-flash
The update-file event said that a file was saved, but not what changed:
the change row (xlog) was the only place with that, and the event did
not point at it. A reaction of the events consumer cannot read a change
it cannot name.
The command now generates the id of the row it inserts and carries it
in the event props, under :file-change-id. The response of the RPC does
not change (the prop goes in the metadata of the result, not in the
result), and the prop is a plain key with a uuid value, so clean-props
keeps it.
A save writes two change rows: the change itself, which has no label,
and the snapshot of the previous state, which has one. The event points
at the change one.
AI-assisted-by: minimax-m3.1-flash
The cached path of the media-storage-bytes-per-team quota reads the
team_storage_usage counter, and that counter only stays true while
somebody marks the teams dirty. That somebody is the events consumer:
with its flag off nobody refreshes the counter and the quota reads a
frozen value, so it lets uploads over the limit pass without a word.
The failure is permissive, which is the one this work came to fix.
The cached path needs the :event-consumer flag too, next to
:storage-usage and :audit-log. Without it the quota falls back to the
live sum: slow on big teams, but true.
AI-assisted-by: minimax-m3.1-flash
The drain locked the audit_log rows it reads, but the rows it writes
are the pendings: each consumer owns its queue and deletes its own
pendings. Locking the shared table made the consumers wait for each
other, and the events consumer had to skip the locked rows with SKIP
LOCKED to avoid that wait.
The lock moves to audit_log_pending, so every consumer locks its own
queue and no consumer waits for another one. The rows of audit_log are
only read now, and every delete that takes them away (the drained
chunk and the orphan sweep) skips the ones another transaction holds
instead of waiting for them.
The SKIP LOCKED variant of the chunk query is gone: the events have to
be executed in order, like an event log, so a second run (another
backend, or a retry overlapping the previous one) waits for the
pendings of the run in flight and takes the next events. Skipping the
locked ones would hand the newer events to the second run while the
older ones were still in flight.
The lock is FOR NO KEY UPDATE, not FOR UPDATE: deleting an audit_log
row makes the foreign key check take FOR KEY SHARE on the pending rows
that reference it, and those belong to the other consumers. FOR KEY
SHARE does not conflict with FOR NO KEY UPDATE, so the check never
waits for a drain in flight and the two consumers cannot deadlock. It
still conflicts with itself, so two runs of the same consumer take
turns.
AI-assisted-by: minimax-m3.1-flash
The event consumer lived in app.tasks.event-consumer, but it is not a
domain task: it is the machinery of the audit log, next to the queues
it drains and next to the other two consumers. The transport was in
app.loggers.audit.consumer-task, a name that says "the task" when it
holds the three of them.
Both are now app.loggers.audit.consumers: the queue helpers, the
drain, the three job-defs and the registry of reactions live in one
namespace, and app.tasks.event-consumer is gone. The team-storage
reaction stays where it was, in app.tasks.team-storage, since it owns
the table and the SQL of its mark.
The tests follow: the consumer test is now
backend-tests.loggers-audit-consumers-test, and the tests of the
reactions live in it.
AI-assisted-by: minimax-m3.1-flash
The third audit-log consumer was selective: the producer knew the list
of event names that feed it, and the transport had to know them too.
Every new case meant touching the producer, the transport and the
metric.
The consumer is now the events consumer: it queues the whole stream
(every event but the telemetry shadows, which duplicate the full row)
and each reaction decides what concerns it. The producer no longer
knows any event name, and the transport only owns the queue, the drain
and the metrics.
The consumer has its own flag, :event-consumer, and its own job,
:audit-log-consumer-events, which lives in the generalist
(app.tasks.event-consumer) next to the registry of reactions. The
drain only decodes the events a reaction claims: the queue takes the
whole stream, but most of it concerns nobody.
The team-storage rule stays where it was, as a reaction of the
generalist, so it keeps marking the teams dirty exactly as before.
The metric label changes from storage-usage to events, with the same
"processed" outcome: it dispatches the stream, it archives nothing.
AI-assisted-by: minimax-m3.1-flash
The transport had three chunk functions repeating the same shape: take
the lock timeout, read the rows of the chunk, do the work and complete
the batch. Only the work differed, and the shape was copied three
times.
The drain is now one function: it owns the transaction per chunk, the
lock mode (the consumers that archive block on the oldest rows to keep
the arrival order; the ones that do not archive take the rows with SKIP
LOCKED, so they never wait on an archive job holding them), the
completion of the batch, the housekeeping and the metrics. Every
consumer only provides the processor that does its work over the rows
of its chunk, and the lock mode is a datum of the consumer table
instead of a hardcoded id.
No behavior change: the same queues drain, the same teams get marked,
the same metrics and the same logs land.
AI-assisted-by: minimax-m3.1-flash
The third audit-log consumer was written for a single case: the list of
event names that feed it lived in the producer, and the rule itself
(which props carry the team id, how a team is marked dirty) lived in
the transport that drains the queues. Every new case meant touching
the producer, the transport and the metric.
The transport keeps only what is generic (the queues, the drain, the
housekeeping, the metrics) and the rules move to a registry of
reactions, one per domain. The team-storage rule moves to the domain
that owns its table (app.tasks.team-storage) and becomes a reaction:
the event names it cares about, the flag that turns it on and the
function that marks the teams dirty. The registry and the dispatch
live in the new app.tasks.event-consumer. A reaction receives the
whole batch of events it declares, so marking the teams dirty is one
write per batch and not one write per event.
No behavior change: the same events queue, the same teams get marked
and the same metrics are recorded. The view of a row that a reaction
sees carries the row id, so a reaction can correlate what it did with
the row it came from; the id does not travel to the archives.
AI-assisted-by: minimax-m3.1-flash
Teach the file-gc task to heal files whose stored data fails schema
or semantic validation instead of failing the nightly run: read once
(sanitize, migrate in memory, check the scheduled revn), run the
shared best-effort repair loop, then take the row lock and persist
the snapshot, marking the file trimmed. A new revision orphaned the
current file thumbnail (the list joins on revn), so a run that
changes bytes touches modified-at instead (the ETag covers it) and
no-op runs touch nothing; the change is detected against the decoded
pre-sanitize data, which get-file now returns as :orig-data. The
handler reports a result map with a keyword reason (:cleaned, :gone,
:stale-revn, :unfixable, :error) and the tiered-storage offload
submit runs outside the result handling, so a queue failure after a
good write stays truthful and only logs its own error. The
library-user scan moved out of the write transaction, so long scans
no longer trip idle-in-transaction timeouts.
Repair convergence lives in one shared loop used by file-gc, the
debug endpoint and the srepl helper: validate, apply the computed
repair changes and repeat up to ten times, returning the input
untouched when it throws or does not converge. file-gc revalidates
after the loop and raises :repair-did-not-converge, so a partial
cleanup is never stored as clean: the classifier reports :unfixable,
the file stays unmarked and the scheduler retries it. Validation
skips shapes without :shape-ref (copy roots legitimately carry
none), so the repair converges instead of crashing on the get-shape
precondition; a dedicated repair for copies that genuinely lost
their ref is future work.
Libraries load through the weak-map loader, and that loader was
broken: the threading macro passed :data to update as the map
itself, so every library load threw and the repair loop swallowed
the exception, silently skipping every repair for files with linked
libraries. The pipeline is now explicit: load, realize pointers
(they take [cfg file], so the reversed -> threading was returning
the Integrant system map) and then sanitize. Unconverted types broke
ref resolution (detaching copies) and hid real uses from
instance-of?, so libraries are sanitized on load, user files are
sanitized inside the library-user scan, and the srepl helper and the
debug endpoint sanitize too. Only migrate when migrations pend.
sanitize-file-data now returns the input instance unchanged when
neither the nil-entry drop nor the schema decode repairs anything:
lazy containers (objects-map) keep their identity and file-gc can
tell a real fix from a no-op by comparing with the pre-sanitize
data. The walk still happens on every call, and that cost is
documented on the loader.
Migration 0027 heals stored data durably with the same recipe the
read path applies: schema-decode unconverted types (precompiled
decoders, explicit component-objects pass) plus the legacy-57 nil
page cleanup that reappeared through a write path. clean-file keeps
only its post-decode fixes and documents the single sanitize pass.
Change-level guards reject :add-page without a valid page id
(structured error, not assert, so release CLJS still enforces it)
and conflicting id/page pairs.
Tests pin the contract with production-shaped corruption builders:
file-gc repair and the outcome matrix, loop convergence and give-up
branches, the :shape-ref guard both ways, page guards, sanitize
idempotence and identity (plain and objects-map), the 0027
migration, a remote copy resolved through the loaded library and the
non-convergence report; existing file suites move to the result map
through the run-file-gc! helper.
Closes#12433
Follow-up:
https://github.com/penpot/penpot/issues/12509
AI-assisted-by: muse-spark-1.3-contributor
AI-assisted-by: glm-5.3-flash
Snapshots, automatic and user, now store pointer-based rows that share
fragments with the live file instead of duplicating page content.
`get-file-snapshot` resolves those pointers on the server so version
previews keep rendering plain data, and `restore!` reuses the shared
rows. Export still materializes (the package carries no fragments) and
import regenerates them.
`file-gc` treats the main file and every unpurged snapshot as liveness
roots, reads the file unrealized, and reattaches the original pointer
wherever the cleaned value is unchanged, so a pass over an untouched
file writes no fragments and a pass over an edited file keeps the ids
of the pages it did not touch. Snapshot creation and the restore
backup take the file row `FOR SHARE`, which serializes them with the
pass and closes the race that let a new snapshot reference fragments
the pass was about to mark.
All backend pointer bindings go through the `fdata/with-pointers`,
`with-tracked`, `with-tracked-pointers` and `with-untracked` macros.
The loader reads fragments a previous pass marked until `objects-gc`
purges them, and the frontend fails loudly with retry instead of
dropping an unresolved fragment.
Review fixes in this branch: the automatic snapshot attaches before
pending migrations, `realize-pointers` is the single verb,
`reattach-unchanged` takes the identity fast path, the restore guard
takes its revn from the backup read, and `PointerMap.withMeta`
generates a fresh id when metadata changes.
Tests cover both snapshot writers (red without the lock), snapshot
sharing and pin release through the real delete and purge paths,
reattach against orphan marking, preview after offload, the pointer
binding contracts and the with-meta id change.
Closes#12410
AI-assisted-by: muse-spark-1.3-contributor
AI-assisted-by: deepseek-v4.1-flash
♻️ Pin no-churn behavior for untouched pages
Pin the behavior the churn verification measured: a save keeps the
fragment id of every page it does not touch, with a page of eleven keys
(PersistentHashMap), a 46-shape objects map and a nine-page index (hash
map). Also pin the mixed case: a data-level objects-map flip rewrites
the old pointer pages once and the next save keeps the new ids.
The memory records the identity rule behind it: re-assoc with the same
value returns the same map in array and hash maps alike.
Closes#12410
AI-assisted-by: deepseek-v4.1-flash
⚡ Stop loading untouched pages when enabling objects-map
`enable-objects-map` wrapped every page on every save, and reading a
page's objects forced its PointerMap to load the fragment. A save of
one page in a seven-page file therefore made seven fragment reads.
Skip unloaded pointers: their fragment was already wrapped by the
encode-file that wrote it, so loading it only to wrap it again is a
no-op. The save now loads only the pages it edits. A page with a plain
fragment (a data-level feature flip) keeps its plain fragment until it
is first loaded or edited; a migration that enables the feature must
rewrite the data itself.
Cover the change with load-count tests for save and restore, update the
flip test to the new contract, and drop the stale restore comment.
Closes#12410
AI-assisted-by: deepseek-v4.1-flash
⚡ Keep page fragment ids through pending migrations
Applying a pending migration on read realized the file, ran the
migration and persisted it again; the persist wrapped every plain page
in a new PointerMap, so every page got a new fragment id even when the
migration did not change it. That leaves the fragment cache cold and
rewrites every fragment on the first read after a deploy.
Capture the original pointers before realizing, reattach the unchanged
positions after migrating (same value comparison file-gc already uses),
persist the reattached file and return the realized one: the read
contract is plain data. Read-only connections skip the persist and the
reattach. A migration that adds objects-map skips the reattach so the
pages get wrapped.
Move the three helpers to fdata so file-gc and the migration path share
them, and cover the path with a real 0031 migration test: the unchanged
page keeps its id, the changed page gets a new one, the caller gets
realized data and a second read does not migrate again.
Closes#12410
AI-assisted-by: deepseek-v4.1-flash
The media-storage-bytes-per-team quota ran a full SUM over
storage_object on every upload, taking seconds on large teams. A new
team_storage_usage table (migration 0158) caches the counter: a third
audit-log consumer (storage-usage) selectively queues the events that
change media storage, marks their teams dirty, and a scheduler turns
the marks into per-team recompute jobs on a dedicated :storage queue.
The quota reads the counter, always answers one row, and fails open
when the row is missing; the three commands that write media bytes pay
it.
The dirty-team stream runs in one read-only transaction and submits
each 50-team chunk through its own writable one (db/own-transaction),
so a killed run keeps what it already enqueued and the next tick
resumes the rest. The recompute only clears marks older than its read,
and the query also returns the rows nobody refreshed for a week, so a
lost mark heals at a bounded rate instead of never.
Producers resolve team ids at emission with one batched lookup, so the
drain never joins production tables: file-gc carries its team, the
delete cascade reports flat and nested ids, moves carry explicit
source/destination teams (mixed origins rejected), restores carry
theirs, and purges carry only their entity key. Selective queueing is
gated per consumer flag on both ingest paths, and telemetry shadows
never queue the admin-console consumer. An audit event that cannot be
stored now fails loudly instead of leaving a consumer stale.
The delete cascade owns library relations: the RPC only marks the file
deleted, and the job captures dependents, removes the relations and
absorbs them under a savepoint, only for survivors. Jobs queued by the
previous release still absorb through a deprecated :file-ids snapshot.
Closes#12462
AI-assisted-by: muse-spark-1.3-contributor
A text image fill was always laid over the whole text box, so a pasted
image span showed another part of the picture in a box of another size.
Text image fills now honour the image transform, as shape fills do: the
image covers the transform rect instead of the whole box. Paste gives
each pasted image span a transform that keeps the image at the same
pixel offset from the span, like the gradients.
The SVG export emits one linked image per span transform, rather than
one per image id, and the style panel keeps the transform it reads back.
AI-assisted-by: claude-opus-5-5
Move the backend JDK to its latest maintenance build within Java 26 and
refresh the dependency versions pinned by hand in the development and
production images.
- Backend: Zulu JDK 26.0.2 to 26.0.2.1. The jlink module list keeps
jdk.management, so the prometheus process metrics stay exported.
- Node.js 24.22.1 in .nvmrc, the development image tarball and the two
plugins deploy workflows that pin it.
- pnpm 12.15.0 in the four Dockerfiles, the 34 packageManager fields and
the eleven lockfiles.
- Development tools: clj-kondo 2026.10.20, Babashka 1.13.226, Pixi
0.83.0, GitHub CLI 2.103.0, AWS CLI 2.38.5 and uv 0.13.5.
- Infra services: RustFS 1.0.3, Mailpit v1.32.0, and Valkey pinned to
8.1.10 instead of the floating 8.1 tag.
Fix SERENA_UPDATE_VERSION in the compose file. A value there makes the
start script reinstall that Serena release on every start, overriding
the one baked into the image; it was replacing 1.7.0 with 1.6.1.
The hardened base image updates are left to Renovate.
Closes#12599
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
When render-wasm is active, shape exports (single file, ZIP, and Export
boards multi-page PDF) run on the shared worker WASM module so the UI
stays responsive. A progressive PDF session (begin/add-page/finish)
updates progress per page; cancel settles on the client. The server
exporter path remains for the SVG renderer.
Text image shaders are baked into Skia paragraphs at layout time.
touch_shapes_with_image only scanned shape.fills/strokes, so text
spans were never invalidated after store_image. Detect text content
image fills and force a layout rebuild before touching tiles.
Closes#12563
Replace the @penpot/tokenscript npm package (the TypeScript
interpreter) with penpot/tokenscript-interpreter, a Clojure and
ClojureScript interpreter that now lives in its own repository and
compiles into the frontend build.
The frontend adapter (app.main.data.tokenscript) resolves tokens with
it behind the existing :tokenscript flag and maps its results to the
values and errors the workspace uses:
- Token values are validated by the interpreter's token types, passed
by their DTCG names: `1foo` is not a dimension, a number has no
unit, an opacity is between 0 and 1. Penpot only adds its own
limits (font weight names, text decoration keywords).
- Division by zero, incompatible units, unknown functions and
function arguments out of range get messages of their own, and
token pills show the message of their error.
- Font family names are quoted before resolving, as in the adapter
this replaces (#11750): `Red Hat` stays one name.
The token form's value input passed the token name to the tokenscript
resolver in place of the typed value, and no name at all for a new
token. It now resolves the typed value under the form's token name, as
the StyleDictionary path does.
StyleDictionary stays the default until the interpreter is stable.
frontend/deps.edn pins the interpreter by tag and commit, v0.1.0.
Signed-off-by: Florian Schroedl <flo.schroedl@gmail.com>
Take the file row with FOR UPDATE SKIP LOCKED when the offload job
starts and skip the run when the row is locked or gone, so the job
never runs while the file is being edited. Every live hot-storage
row of the file moves to cold storage inside one all-or-nothing
transaction, and the handler answers a summary map with the
offloaded rows, bytes and per-type stats.
Add bounded metrics for the job: rows and bytes counters labeled by
row type on successful runs only, and a timing histogram with the
ok, skip and error outcomes. The job definition now requires
metrics, the recorders live in app.jobs.metrics, and the existing
ephemeral job timing histogram pins its buckets.
Cover the job with a new test namespace: the happy path, the locked
row, missing and deleted files, the rows and bytes summary with its
no-op rerun, the whole-file abort on storage failure, the metric
definitions and emissions, and the submit-time params validation.
An end-to-end suite reads, snapshots, writes, exports, imports and
deletes offloaded files; concurrency tests pin the stale-revn skip,
the blocked-edit retry and the reclaimable orphans of a partial
failure. The file-gc scheduler test pins one job per eligible file,
and the scheduler private helper drops its bang.
Document the lock invariant, the deliberate no-pacing decision and
the metric label sets in the backend storage memory and the
configuration guide.
Related to #12398.
AI-assisted-by: muse-spark-1.3-contributor
The cron tick kept its own active-job count beside the shared
submit option. Delegate the no-overlap check to
::jobs/skip-if-active so both paths share one queue-qualified
check served by the partial index.
A tick now skips only when a live job sits on the :cron queue,
and a skip records both the cron counter and the generic skipped
counter.
Closes#12411
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Keep text gradients in place when pasting at the caret
Text gradients are stored as fractions of the whole text box. Paste
copied those fractions unchanged, so in a box of another size the
handles and colours moved away from the pasted text.
Copy from the v3 editor now records where each span with a gradient or
image fill starts in its text box, and the box size, in the clipboard
payload. A new WASM export, text_editor_get_range_origin, gives the
start of any text range.
Paste at the caret finds where those spans start in the target and
rewrites their gradients so the handles keep the same pixel offset from
the span. Auto-sized texts use the box size they get when the editor
closes.
AI-assisted-by: claude-opus-5-5
* 🐛 Rebuild text gradient shaders when the text box resizes
Text gradient and image shaders are built into the cached Skia
paragraphs against the text box size. A resize with no content change
reused those paragraphs, so the colours stayed matched to the old box
until a reload.
The text now remembers the box size its shaders were built for and lays
itself out again when that size changes and a span has a gradient or
image fill. Text with only solid fills keeps its cache.
AI-assisted-by: claude-opus-5-5
* 🐛 Keep text gradients in place when pasting on the canvas
A text selection pasted on the canvas becomes a new text whose box
differs from the source, so its gradients moved like they did for caret
paste.
After adding the new text, lay it out, find where its gradient spans
start and move their gradients to keep the same pixel offset from the
span, in the same undo step. The range origin export now takes a shape
id, so it works on texts that are not being edited.
AI-assisted-by: claude-opus-5-5
* 🐛 Keep text gradients in place when pasting copied texts at the caret
Text shapes copied whole, rather than as an editor selection, reached the
caret paste without any geometry, so their gradients still moved away from
the pasted text.
A shape copy now records where the gradient and image spans of each
selected text start, and the box size, by shape id. Paste joins them in
the order of the joined paragraphs, with no origin for texts copied
without one.
Copied texts each have their own box, so every span origin now carries
its own box size instead of one box for the whole payload.
AI-assisted-by: claude-opus-5-5
* 🐛 Skip unknown entries when importing shortcuts
The shortcuts import rejected the whole file when it held a single entry
it did not know, so a profile exported from an older Penpot version, or
edited by hand, could not be imported at all. The spec in #9924 asks to
skip unknown keys, tell the user which ones, and apply the rest.
Importing now keeps the entries that map to a known shortcut with a valid
key combination and skips the rest: unknown contexts, unknown shortcut
keys, and commands that are not a key combination. The import modal
lists the skipped entries. The file is only rejected when it is not a
map or holds nothing that can be applied.
A context whose entries are all skipped is left out: an empty context
clears the user's shortcuts on import, so keeping it would wipe them.
valid-command? reads a command the way mousetrap does, so it accepts
every default shortcut and rejects strings mousetrap could never match.
AI-assisted-by: claude-opus-5-5
* 📎 Sort requires in shortcuts settings test
cljfmt expects the namespace requires in alphabetical order.
AI-assisted-by: claude-opus-5-5
* ✨ Reserve cancel, copy and paste shortcuts
Cancel, copy and paste are no longer customizable, in the workspace and
in the path editor, which shares their shortcut keys. Their key
combinations (Escape, Ctrl/Cmd+C, Ctrl/Cmd+V) are now reserved: the
shortcut recorder warns and blocks saving one of them for another
action, and the import skips entries that use them.
Reserved combinations come from the non-customizable workspace
shortcuts, so marking another one as non-customizable reserves its key
too. The path editor's cancel also binds V, the move tool key, so path
shortcuts are left out. Combinations compare the way mousetrap matches
them: aliases and modifier order do not matter.
apply-custom-overrides now ignores overrides of non-customizable
shortcuts. An override saved before this change would otherwise still
apply, with no way to reset it from the settings page.
AI-assisted-by: claude-opus-5-5
* 🐛 Pass version tag to worker so it loads the current render wasm
* 🐛 Keep wasm thumbnail queue alive when one render fails
* 🐛 Fail wasm thumbnail request when canvas context cannot init
* ♻️ Use DS icons and an icon button in the undo history panel
The undo history panel (History > Actions) drew its entry type icons
with the deprecated icon set, and the arrow that shows the details of an
entry was a div with a click handler: no button role, no keyboard
access and no accessible name.
- The entry type icons use the DS icon (drop-icon is i/drop in the DS).
- The arrow becomes a DS action icon button labeled "Show details", with
aria-expanded for its state. It keeps its data attribute, its classes
and its click handler, and it also shows on keyboard focus.
The DS icon draws 12px inside a 16px box, so the entry icon gets a
negative margin, and the button wrapper a negative end margin: icon,
arrow and text stay where they were. The arrow turns through its own
class instead of an svg rule.
Add the workspace.undo.entry.show-details key (en, es) and a visual test
that draws a rectangle, opens the actions list and expands the entry.
AI-assisted-by: claude-opus-5-5
* ♻️ Move undo history panel styles off the deprecated styles
Move history.scss to DS tokens, sizes, borders and logical properties,
and drop the refactor import. The entry color aliases become local
variables of the entry, set by its disabled and hover states, and the
arrow opacity goes through one as well.
AI-assisted-by: claude-opus-5-5
* 💄 Place the undo history icon and arrow without negative margins
Drop the -2px margin of the entry icon and the -6px end margin of the
details button wrapper. Keep text and arrow in place with token spacing
instead: the text after the 16px icon box starts with --sp-xs instead
of --sp-s, and the entry end padding is --sp-xxs, since the 24px button
leaves 6px around its 12px arrow. The icon now sits 2px to the right,
centered in its box.
AI-assisted-by: claude-opus-5-5
* ✨ Support running several exporter replicas
* 📚 Document one exporter per machine instead of compose replicas
* 📚 Trim exporter replica docs to the essentials
* ✨ Cancel an exporter's jobs when it shuts down
* 📚 Replace exporter replica docs with a short beta note
* ♻️ Use DS icon buttons for the bottom palette toggles
The color palette and typographies toggles of the bottom palette were raw
buttons with deprecated icons. Replace them with DS ghost icon buttons
with small icons, as before, that show the open palette through
aria-pressed. The layout flags are keywords, so they are coerced to a
boolean for aria-pressed. Their title moves to the label, shown in the DS
tooltip with the shortcut.
The palette actions button spans the palette height, not a DS button
size, so it stays raw; only its deprecated icon moves to the DS icon.
Add a visual test that compares the palette with each toggle selected
with no tolerance.
AI-assisted-by: claude-opus-5-5
* ♻️ Move bottom palette styles off the deprecated styles
The palette toggles are DS icon buttons, so their placeholders go away.
The actions button keeps the tertiary button look through local state
variables, and its icon takes the color from the button. Spacing, sizes,
borders, opacity and the color aliases move to DS tokens and logical
properties, except right and width of the palette, which are the
transitioned properties. The grid template is split into areas, rows and
columns, since Sass reads its slash as a division with a size variable.
The z-index values came from the legacy global scale. The resize area,
the toggles and the actions button had their own (4, 2 and 2), plus 0
overrides while collapsed, but they sit in separate grid areas and the
palette content does not reach them: removing them changes no pixel with
the text or color palette, the actions menu open, the 80px size, or the
palette collapsed and hidden. Only the palette's own z-index stays, with
a comment: it keeps the palette above the viewport overlays (z-index 1),
and drops to their level while collapsed.
The help button styles are not used in palette.cljs, but the external
chat widget injected into the palette wrapper may use them, so they stay,
inlined on DS tokens.
palette.scss no longer imports refactor/.
AI-assisted-by: claude-opus-5-5
* ♻️ Use a DS icon button for the bottom palette actions
The palette actions button (⋮) stayed a raw button because it spans the
palette height, not a DS button size. The DS icon button takes its
height from --button-height, so a class now stretches it to the palette
and the hand-made colors and states go away. It uses the ghost variant,
like the palette toggles next to it.
The button had no accessible name and did not say that it opens a menu:
it now has the "Palette options" label (new en and es keys) with its
tooltip, aria-expanded and aria-haspopup. The class given to its icon
did not exist in palette.scss and is gone.
Only the button corners change in the palette screenshots: the raw
button painted its own rounded background, the ghost button is
transparent at rest.
AI-assisted-by: claude-opus-5-5
The Admin Console location built its proxy_pass target from a capture of
$request_uri, which nginx escapes again when the path has escaped
characters: %XX became %25XX and the query string ended up in the path,
so Admin Console links returned 404. Strip the prefix with a rewrite and
let proxy_pass forward the rewritten URI instead.
AI-assisted-by: Claude
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
The layers panel already uses DS icons and buttons; its two raw buttons
(the layer filter pill and the focus mode title) stay raw, since they are
custom widgets. Their styles still used the deprecated %button-tag
placeholder and color aliases.
- Inline %button-tag in the filter pill: border, cursor, centering, and
its focus and pressed states through local variables. Its hover rule
set the icons to the color they already had, so it goes away.
- Resolve the icon, title, pill and menu color aliases to DS tokens. The
filter menu item texts are the same color in every state, so only the
background changes, through a local variable.
The refactor import stays for the shared %menu-dropdown placeholder.
Add a visual test of the focus mode title, a filter pill and the filter
menu; the panel looks the same as before, pixel for pixel.
AI-assisted-by: claude-opus-5-5
The boolean operation buttons at the top of the Design tab showed the
2px border of the legacy radio buttons. The design team is reworking
this section; for now only the border goes away, in normal and selected
state, by making the border color variables transparent there.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS buttons in the workspace right header
The comments and history buttons were raw buttons with deprecated icons;
they become DS ghost icon buttons that show their state through
aria-pressed, with the unread dot as a child of the comments button. The
view mode link had a click handler and no href; it becomes a DS icon
button too, so it gets a button role and keyboard activation. Their
tooltips open below and to the left, at the top right corner of the
screen. The zoom menu's Reset is a DS ghost button.
The share link stays raw with its deprecated icon until the DS has a
share icon; it now uses its own class instead of the view mode one.
Add a visual test that captures the header at rest, with comments
active, and with the zoom menu open.
AI-assisted-by: claude-opus-5-5
* ♻️ Move workspace right header styles off the deprecated styles
The comments, history and view mode buttons are DS icon buttons, so their
placeholders go away; the comments class only holds the unread dot. The
zoom widget color goes through a local variable, and spacing, sizes,
borders and the color aliases move to DS tokens and logical properties.
Delete the save status rules: the status widget moved to the left header
in #5773 and nothing here uses them anymore.
refactor/ stays only for the shared menu placeholders and for the
tertiary button and icon placeholders of the share link.
AI-assisted-by: claude-opus-5-5
* 🐛 Give the action icon button a color while pressed or active
The action variant of the DS icon button set the active background but
not the active foreground, so it kept the base value, initial. A custom
property set to initial is invalid at computed time, so the icon took
the color of its container while the button was pressed (:active) or
toggled on (aria-pressed): yellow in the workspace header, for example.
Use the accent color, as the ghost and secondary variants do.
AI-assisted-by: claude-opus-5-5
* 💄 Use the action variant for the right header icon buttons
The comments, history and view mode buttons had no background at rest,
on hover or while active. The DS action variant keeps that: no
background at rest and on hover, accent icon on hover. It is 24px
instead of the ghost 32px, and shows a small background while comments
or history is on.
The unread dot moves to 4px from the button corner, so it stays over the
top right corner of the icon in the smaller button.
AI-assisted-by: claude-opus-5-5
* 💄 Drop the background of a pressed action icon button
A toggled-on action icon button (aria-pressed) showed the quaternary
background next to the accent icon. Keep only the accent icon, as the
workspace header buttons looked before the DS migration. The background
stays for the click feedback (:active).
Only the right header comments and history buttons use the action
variant with aria-pressed today.
AI-assisted-by: claude-opus-5-5
* 💄 Widen the gap between the right header actions
The action icon buttons are 24px wide, smaller than the old ones, so
the comments, history and view mode buttons sat closer together than
before the DS migration. Raise the header gap from --sp-s to --sp-m to
keep the old spacing between the icons without resizing the buttons.
The separator absorbs the extra space, so the header width stays the
same.
AI-assisted-by: claude-opus-5-5
* ♻️ Make the right header zoom widget a button
The zoom percentage of the right header opened its menu from a div with
a click handler: no button role and no keyboard access. Make it a
button with aria-expanded, keeping its classes, title and handler; its
visible percentage is its accessible name. Reset the browser padding of
the button so the percentage stays in place.
AI-assisted-by: claude-opus-5-5
* ✨ Toggle the zoom dropdown from the zoom widget
Clicking the zoom widget while its dropdown was open left it open: the
handler always set the open state, and it stops the click before the
dropdown sees it. The widget now toggles the dropdown, as its
aria-expanded already suggests. Closing from outside or with Escape
works as before.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS buttons and icons in the plugins dialogs
The plugins manager and the permissions, permissions update and try-out
dialogs used raw buttons, input elements styled as buttons and
deprecated icons. Replace them with the DS components:
- The four close buttons become ghost icon buttons labeled "Close", shown
in their DS tooltip. The class that places them in the corner goes on
the tooltip wrapper, so the tooltip anchors to the button.
- Install, and the cancel and accept buttons of the three dialogs, become
DS primary and secondary buttons.
- The empty list puzzle and the external link icons use the DS icon.
The permission icons (oauth-*) stay: they are brand art.
Add a visual test with fixtures for a team with the plugins feature and
a profile with one installed plugin. It shows the empty manager, the
manager with a plugin and the permissions dialog.
AI-assisted-by: claude-opus-5-5
* ♻️ Move plugins dialog styles off the deprecated styles
Remove the styles the DS buttons and icons no longer need (close-icon,
primary-button, cancel-button, search-icon and the svg rules of the
migrated icons) and move the rest of plugins.scss to DS tokens, DS
typographies and logical properties.
- The close button wrapper keeps only its position; the deprecated
tertiary button style was landing on the tooltip wrapper.
- The full-width cancel and accept buttons center their label again: the
DS button does not center it when it grows.
- The external link icon gets a class instead of an svg rule.
- The deprecated body-small used line-height 1.4 and the DS one 1.3, so
the permissions dialog is a few pixels shorter.
The refactor import stays for the modal placeholders until the modals
move to the DS modal.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS buttons in the debug shape panel
The object and tree dump buttons of the debug shape panel were raw
buttons with the browser default look. Use DS secondary buttons. The
DS buttons are taller, so the title row centers the shape name.
Add a visual test that turns on the shape-panel debug option and shows
the title row of the selected shape.
AI-assisted-by: claude-opus-5-5
* ♻️ Move debug shape panel styles off the deprecated styles
Move debug_shape_info.scss to DS spacing tokens, borders and logical
properties, resolve the panel background alias, and drop the refactor
import. The two font sizes match no DS typography, so they stay with a
TODO, and the unused shape-info-title class is commented out for a
manual check. The panel looks the same as with the previous commit.
AI-assisted-by: claude-opus-5-5
* 🐛 Let the debugging tools options scroll
The debugging tools panel lists 32 options in the left sidebar, whose
content area hides what overflows. On a short window the last options
were cut off with no way to reach them. Make the options list fill the
space under the title and scroll on its own.
AI-assisted-by: claude-opus-5-5
* ♻️ Move debugging tools panel styles to DS tokens
Move debug.scss spacing, size and the panel background alias to DS
tokens and logical properties. The refactor import stays only for the
checkbox placeholders, until the checkboxes move to the DS. The panel
looks the same as before.
AI-assisted-by: claude-opus-5-5
* 🐛 Fix legacy path migrations on content without coordinates
PRE still returned 500 on get-team-shared-files after #12357, now with
"expected rect instance" in legacy-6. When a path has no points, legacy-6
rebuilds its rect from the content, but content whose points are not
numbers (NaN or infinite coordinates, segments without params, only a
close path) gives no rect either, and rect->points rejects nil.
Fall back to the empty rect in legacy-6. A path with segments missing
coordinates then failed later, in 0003-convert-path-content-v2, since the
binary path data can not store them; drop those segments there.
Relates to #12343
AI-assisted-by: claude-opus-5-5
* 🐛 Skip shared files that fail to load in get-team-shared-files
get-team-shared-files loads and migrates every shared file of the team,
so one file whose migration fails made the whole request return 500 and
the "Add library" modal could not open.
Catch the error per file, log it with the file and team ids, and leave
that file out of the list. The other libraries still show, and the error
report names the broken file.
Closes#12343
AI-assisted-by: claude-opus-5-5
`SET STORAGE DEFAULT` needs PostgreSQL 16, but the official docker
images pin `postgres:15`, so migration 0156 failed on those
deployments. The migration now uses `SET STORAGE EXTENDED`, the
storage mode of the type of every column it touches (text, text[],
jsonb and bytea), which leaves the catalog in the same state on any
supported version.
The documented PostgreSQL floor goes back to >=15, the reason for
raising it was that syntax.
Verified on the test database: the 62 columns reset and no table
column keeps the legacy EXTERNAL storage.
The backend memory now records that migrations must stay within
PostgreSQL 15 syntax.
AI-assisted-by: minimax-m3.1-flash-free
The prod-infra memory now says that the `$host` multi-variable needs
its `query`, its `options` list and its `allValue` regex updated
together. A host present only in `query` is dropped when `All` is
selected, so the panels that filter with `instance=~"$host..."` stop
showing it. Panel 104 is the visible symptom.
AI-assisted-by: minimax-m3.1-flash-free
`app.redis/del` logged `:key key`, but `key` is not bound in that
`let`: it resolved to `clojure.core/key`, so the line carried
`#object[clojure.core$key ...]` instead of the keys that failed to be
deleted. Log `:keys keys`, which is always the normalized vector, so a
single key and a collection report the same shape.
The tests pin the line: the keys, the hint, the error level, the logger
and the timeout as cause, plus the `nil` the caller gets.
The prod-infra memory now notes that client-side redis timeouts mean the
JVM is paused, not that the server is slow.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Fix render_to_pdf call in the ToUnicode test
The test passed a scale argument that render_to_pdf does not take, so
the render-wasm test build failed to compile.
* 🐛 Draw masked group inner shadows in PDF and multi-element export
The CPU renderer behind PDF and multi-element raster export drew a
masked group's drop shadow but never its inner shadow, so the masked
content came out flat. Wrap the masked content in a layer that draws
the inner shadows over it, as the GPU single export does.
* 🐛 Export every visible inner shadow of a masked group
The workspace skips shadows too small to see at the current zoom, to
render faster. Export has no such need, so draw every visible inner
shadow instead of filtering by scale.
Noto Sans Symbols lacks most arrows and math operators. Fall back to
Noto Sans Math for them, and to Noto Sans for letterlike symbols.
Closes#12469
AI-assisted-by: claude-opus-5-5
* ✨ Auto-scroll sortable lists while dragging near their edges
Native drag and drop swallows wheel events, so a long list could only be
scrolled mid-drag by hitting the browser's narrow auto-scroll strip at the
very edge of the container.
use-sortable now scrolls the item's nearest scrollable ancestor on every
animation frame while the pointer sits within 56px of its top or bottom
edge, ramping the speed up to 18px per frame as the pointer gets closer.
Every list built on the hook (layers, sitemap, option rows, gradients,
token sets) gets it without any change on their side.
* ✨ Cap auto-scroll zones and test the scroll loop
Each edge zone now takes at most a quarter of the container, so the
middle half of a short list, like the pages panel, never scrolls.
The scroll container is found once when the drag starts. The speed is
scaled by the time between frames, so 120 Hz screens no longer scroll
twice as fast. Starting a drag also stops any loop left over from a
drag whose end event was lost.
auto-scroll-speed is now private. New tests cover the running loop:
only one loop starts, it stops on drag end, when the speed goes back
to zero and after 500ms without dragover, and the frame time scaling.
AI-assisted-by: claude-opus-5-5
---------
Co-authored-by: Shreyash Agare <agareshreyash26@gmail.com>
Co-authored-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
WASM find_guide_at only checked distance on the guide axis, so board-bound
guides stayed interactive outside their board. Respect frame_range in
GuidePool hit testing so interaction matches the drawn solid segment and
the SVG hit area.
Closes#12322