The bullet list toggle now saves list-style-type ("none" or "disc") on
the selected paragraphs and reads it back. A paragraph without the attr
counts as "none", so a bullet paragraph next to an older one shows as
mixed.
WASM keeps the value on each paragraph: the paragraph bytes grow from
12 to 16, Enter keeps it on the new paragraph, and the v3 editor reports
it in the current styles, so the sidebar shows it while editing too.
Refs #12630
AI-assisted-by: claude-opus-5-5
* ♻️ Move viewport top bar styles off the deprecated styles
The viewport top bars (view only bar of the inspect tab, path edition
bar) already use DS buttons. Move top_bar.scss to DS sizes, borders,
spacing tokens and logical properties, resolve the panel and shadow
color aliases, and drop the refactor import. The unused done-btn class,
the last user of the deprecated button placeholders here, goes away.
The two z-index values have no DS token, so they stay with a TODO.
Add a visual test of the view only bar; it looks the same as before,
pixel for pixel.
AI-assisted-by: claude-opus-5-5
* ♻️ Use the body-small typography in the viewport top bar title
The title only set a 12px font size, which body-small already has.
Use the DS typography instead of the inline size and its TODO. The
view only bar looks the same, pixel for pixel.
AI-assisted-by: claude-opus-5-5
* ✨ Close search and shortcuts panels with Escape
The workspace search and shortcuts panels could only be dismissed with
the mouse, which interrupts keyboard-first workflows.
A new use-escape-to-close hook gives each panel an on-key-down handler
that closes it on Escape while the user is working in it: the latest
pointer press or focus change must have landed inside the panel, or the
panel must hold focus when it opens (a panel that autofocuses its search
box, such as Shortcuts, is closed by the first Escape). Focus left
behind in a panel after the user moved on to the canvas does not count,
so Escape keeps its usual meaning there.
The handler never stops the event. The global Escape shortcut still
runs and finishes in-progress operations such as path drawing; an
earlier version stopped propagation and left a half-drawn path that
failed shape validation on the next commit.
In the search panel an open filters menu takes the first Escape and
the panel closes on the second.
The history panel needs no handler: Escape already closes it through
the :interrupt it emits.
Closes#9540
AI-assisted-by: claude-opus-5-5
AI-assisted-by: claude-sonnet-5-5
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
* 📎 Add e2e test for closing panels with Escape
Cover the Escape handling added for the layers search and shortcuts
panels with four Playwright cases on the mocked workspace:
- Escape closes the layers search panel.
- With the search filters menu open, the first Escape closes only the
menu and the second closes the panel.
- Escape closes the shortcuts panel right after it opens with `?`. The
panel autofocuses its search box, so the first Escape must close it
instead of only blurring the box.
- A path drawn while focus sits in the shortcuts panel is finished by
Escape, which proves the panel does not swallow the global shortcut.
The first three cases fail on develop. The shortcuts case also fails
when the hook does not seed its tracker from the focused element.
AI-assisted-by: claude-sonnet-5-5
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
* 🐛 Close History with Escape when opened by shortcut or menu
Only the header button opened History through initialize-history, which
removes the :document-history flag on the next :interrupt. The
Ctrl/Cmd+Alt+H shortcut and the "Version history" menu item toggled the
flag directly, so the global Escape left the panel open. Both now emit
initialize-history, like the header, and keep their own event origin.
Two e2e cases cover the shortcut and the menu; both fail without this
change.
AI-assisted-by: claude-sonnet-5-5
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
* ♻️ Apply review suggestions to Escape-to-close
Use dom/child? instead of .contains in the Escape hook and drop the
redundant nil checks. Drive the path test through clickAt so its clicks
wait for the path tool. Add a negative Search case: Escape reaches the
panel after the last click landed on the viewport, and the panel stays
open. That case fails when the containment check is removed.
AI-assisted-by: claude-sonnet-5-5
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
---------
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
* ♻️ Use DS buttons and icons in the viewer header
The section buttons, full screen and edit become ghost DS icon buttons
(the last two were spans), with aria-pressed where they toggle. Share
and "Log in or sign up" become DS buttons. The zoom widget becomes a
button that toggles its menu, like the workspace one, with DS zoom
buttons inside. The breadcrumb and the current board name become
buttons with aria-expanded, and the page menu moves next to its
button. The arrow and tick icons use DS icons. The logo stays: it is
brand art.
A visual test covers the header in two sections and both menus.
AI-assisted-by: claude-opus-5-5
* ♻️ Move viewer header styles off the deprecated styles
Spacing, sizes, borders, typographies and color aliases move to DS
tokens and logical properties, and the button placeholders go away.
The section buttons column goes from a fixed 92px to auto, since the
three 32px DS buttons no longer fit; the 1fr side columns keep it
centered. The zoom widget follows the workspace header one. The 11px
project name becomes the 12px DS headline-small. The global .selected
rule also highlighted the current page in the page menu: that row
keeps its highlight through its own rule. The refactor/ import stays
only for the shared menu placeholders; the options z-index (10) has no
DS token and keeps a TODO.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS icon buttons in the viewer board navigation
The previous, next and first board buttons of the viewer become
secondary DS icon buttons with the DS arrow and reload icons. They keep
their size and the viewer background at rest; on hover they take the DS
secondary look. The first board button had no accessible name; it is
now labelled. The position classes move to the tooltip wrappers, so the
fullscreen and comments sidebar offsets still apply.
New key: viewer.go-to-first-board (en, es). A visual test covers the
buttons at rest and on hover.
AI-assisted-by: claude-opus-5-5
* ♻️ Move viewer styles off the deprecated styles
Spacing, sizes, borders, typographies and color aliases move to DS
tokens and logical properties, and the file no longer imports
refactor/. The section scrollbar uses the DS custom-scrollbar mixin; the
text selection and placeholder styles that %new-scrollbar gave to the
comments layer stay, with DS tokens of the same value. An invalid
padding-right with four values, which the browser ignored, is gone.
The DS body-small typography differs from the old one only in a more
compact line-height, so the board counter sits 1px higher. The z-index
values (10, 2) have no DS token and keep a TODO. The .viewer-header,
.not-fixed, .fixed and .frame-children rules are not used by this module
and are commented out with a TODO.
AI-assisted-by: claude-opus-5-5
* 🔥 Remove unused styles from the viewer
The SCSS refactor of the viewer left rules commented out with a TODO
because the module does not use them, or because they target an svg
inside a DS icon that is the svg itself. CSS modules scope the classes
to this module and no other source uses them, so they go:
.viewer-header, .not-fixed, .fixed, .frame-children g. No visual change.
AI-assisted-by: claude-opus-5-5
Text copied in Penpot carries its content in a data attribute of the
clipboard HTML, which plugins and other apps can rewrite. The v3 editor
and canvas paste used it as is, so bad data could reach WASM and the
file and break the shape.
The payload is now dropped, and the paste falls back to plain text in
the caret style, unless its content passes the text shape schema, its
span geometry holds only safe numbers and its file and team ids are
uuids. Payloads in HTML over the paste size limit are not decoded.
Copied text shapes pasted into the editor get the same checks, plus a
numeric position; when they fail, nothing is pasted.
Closes#12167
AI-assisted-by: claude-opus-5-5
* ♻️ Make the grid track direction icon a button
The direction icon at the start of each grid track value field selected
that column or row from a div with a click handler: no button role, no
name and no keyboard access. Make it a button labeled "Select column" or
"Select row", keeping its class and handler, and reset the browser
border so it looks the same. It stays a raw button: it is a segment of
the track field, not a DS button.
Add the workspace.layout-grid.editor.select-column and select-row keys
(en, es).
AI-assisted-by: claude-opus-5-5
* ♻️ Make the component swap panel groups buttons
The groups of the component swap panel opened from a div with a click
handler: no button role and no keyboard access. Make each group a
button, keeping its class, title and handler, and reset the browser
button styles so it looks the same. Its name is the visible group name.
Add a visual test with a fixture whose components sit in two groups, so
the swap panel lists them.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS icons in the viewer comments filter
The "Comments" filter trigger in the viewer header was a div with a
click handler, and its menu lived inside it. It is now a button with
aria-expanded and aria-haspopup, and the menu sits next to it in a
wrapper, since a button cannot hold the menu entries. The arrow and the
ticks use DS icons. The menu entries stay as they are.
A visual test covers the filter closed and open.
AI-assisted-by: claude-opus-5-5
* ♻️ Move viewer comments styles off the deprecated styles
Spacing, sizes, borders, typographies and color aliases move to DS
tokens and logical properties. The DS icons take their color from the
text and no longer shrink, so the old icon placeholders go away. The
DS body-small line-height is more compact, so "Comments" sits 1px
higher. The refactor/ import stays only for the shared menu
placeholders. The z-index values (1 and 10) have no DS token and keep a
TODO.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS icon buttons in the viewer thumbnails panel
The expand, close and scroll buttons of the viewer thumbnails panel
become ghost DS icon buttons. They had no accessible name; they are now
labelled, and the expand button says whether the panel is expanded. The
rotated legacy arrow gives way to the DS arrow icons. The thumbnails
stay raw buttons, since they are list items.
New keys: labels.expand and viewer.thumbnails.scroll-left/right (en,
es). A visual test covers the panel collapsed, on hover and expanded.
AI-assisted-by: claude-opus-5-5
* ♻️ Move viewer thumbnails panel styles off the deprecated styles
Spacing, sizes, borders, typographies and color aliases move to DS
tokens and logical properties, and the file no longer imports
refactor/. The scroll buttons keep their 60px height through
--button-height and still show only on hover; their tooltip wrappers
take the grid cells. The DS body-small typography differs from the old
one only in a more compact line-height, so the counter and the board
names sit 1px higher. The panel z-index (10) has no DS token and keeps
a TODO.
AI-assisted-by: claude-opus-5-5
* ♻️ Use a DS icon in the grid editor track options button
The options button of each grid track on the canvas drew a deprecated
icon and had no accessible name. Use the DS menu icon and label the
button "Track options". The button stays raw: it lives on the canvas,
where the track editor keeps a fixed screen size at any zoom.
The DS icon has a fixed 16px size, so its class scales it down with the
zoom (transform: scale(1 / zoom)), as the old svg rule did with its
width. It looks the same, but for a few antialiasing pixels.
Add the workspace.layout-grid.editor.track-options key (en, es) and a
visual test of a track at two zoom levels.
AI-assisted-by: claude-opus-5-5
* ♻️ Move grid editor styles off the deprecated styles
Move grid_layout_editor.scss to DS sizes, borders, spacing tokens,
colors and logical properties, and drop the refactor import. The canvas
sizes keep their division by the zoom. The grid editor color aliases
resolve to their DS tokens, and the unused close-btn class, the last
user of the deprecated button placeholders here, goes away.
The track editor and the editing bar look the same as before.
AI-assisted-by: claude-opus-5-5
* ♻️ Name the grid track expand buttons
The menu button that expands the columns or rows of a grid layout had no
accessible name. Label it "Columns" or "Rows" and expose its state with
aria-expanded. The track title next to it also toggles on click; it
stays a div, as a larger pointer target for the same action, so the
keyboard does not stop twice on it.
Add the workspace.layout-grid.editor.columns and rows keys (en, es).
AI-assisted-by: claude-opus-5-5
* ♻️ Make the annotation title toggle a button
The annotation title of a component toggled from a div with a click
handler, and it holds the edit, delete, save and discard buttons, so it
cannot become a single button. Move the toggle to a button with the
arrow and the "Annotation" name, with aria-expanded, that takes the
whole free width of the title. While editing or creating, the title
stays text and no longer collapses the annotation on click, as its
cursor already suggested.
Extend the annotation visual test with the collapsed state; it looks the
same as before, pixel for pixel.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS buttons and icons in the export assets modal
The export modal close button becomes a ghost DS icon button and the
progress toast close button an action one, which fits its 24px grid
column; both are labelled "Close". Cancel and Export become DS buttons.
The checkbox ticks and the toast icons use DS icons. The selection rows
stay raw buttons, since they are list rows, and so does the link-styled
retry/cancel button of the toast.
The export frames e2e spec now finds the Export button by role, and a
visual test covers the modal with all, some and no rows selected.
AI-assisted-by: claude-opus-5-5
* ♻️ Make the export progress ring a button
The export progress ring in the header opens the export details, but it
was a div with a click handler. It is now a button named after the
export status, with aria-expanded, and keeps its look.
AI-assisted-by: claude-opus-5-5
* ♻️ Move export assets modal styles off the deprecated styles
Spacing, sizes, borders, typographies and color aliases move to DS
tokens and logical properties, and the button placeholders go away.
The classes left without use are removed: the old modal buttons, the
old close icon, and the file export entries, which live in files.scss.
The refactor/ import stays only for the shared modal and checkbox
placeholders, which move to the DS with the modals and the checkboxes.
The checkbox placeholder shrinks a ticked icon to 12px, which pushed
the DS tick off center: the tick keeps its 16px box now, and does not
shrink inside the 14px flex box either.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS icons in the text palette and its menu
The scroll arrows of the text palette and the tick of the selected
palette in its menu used deprecated icons; use the DS arrow and tick
icons instead. The arrows are full-height scroll buttons with a fade, not
DS button sizes, so they stay raw buttons.
Add a visual test that opens a file with 16 local typographies, so the
arrows show, and captures the palette at rest, with the pointer over the
right arrow, after scrolling and with its menu open. Its fixture is the
library fixture with more typographies.
AI-assisted-by: claude-opus-5-5
* ♻️ Move text palette styles off the deprecated styles
Inline the button, flex and ellipsis mixins, move spacing, sizes and
borders to DS tokens and logical properties, and resolve the color
aliases to their DS tokens. The arrow, item and menu colors that change
on hover, disable or selection go through local variables, and the new
icons take them through currentColor instead of svg selectors. The three
typography lines share their ellipsis rule. The .disabled ::after rule
of the left arrow never matched and goes away; :disabled::after already
covers it.
The items, the empty message and the menu use the DS body-small
typography, whose line-height is 1.3 instead of 1.4. The arrows keep
their z-index, now commented, and the menu its legacy context menu one.
text_palette.scss and text_palette_ctx_menu.scss no longer import
refactor/.
AI-assisted-by: claude-opus-5-5
* 🐛 Show the text palette scroll arrows at rest
The enabled scroll arrows of the text palette had no color at rest, so
they only showed on hover, while the color palette arrows show in the
secondary color. Give them the same rest color.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS icons in the color palette and its menu
The scroll arrows of the color palette and the tick of the selected
palette in its menu used deprecated icons; use the DS arrow and tick
icons instead. The arrows are full-height scroll buttons with a fade, not
DS button sizes, so they stay raw buttons. The color cells are swatches
and stay as they are.
Add a visual test that opens a file with 24 local colors, so the arrows
show, and captures the palette before and after scrolling and with its
menu open. Its fixture is the library fixture with more colors.
AI-assisted-by: claude-opus-5-5
* ♻️ Move color palette styles off the deprecated styles
Inline the button, flex and ellipsis mixins, move spacing, sizes and
borders to DS tokens and logical properties, and resolve the color
aliases to their DS tokens. The arrow and menu item colors that change
on hover, disable or selection go through local variables, and the new
icons take them through currentColor instead of svg selectors.
The empty palette message and the menu items use the DS body-small
typography, whose line-height is 1.3 instead of 1.4. The arrows keep
their z-index, now commented: the left arrow comes before the
positioned color list, so without it the colors would cover its fade.
The menu keeps its legacy context menu z-index, also commented.
color_palette.scss and color_palette_ctx_menu.scss no longer import
refactor/.
AI-assisted-by: claude-opus-5-5
* 🐛 Fix restrict per-side stroke width tokens to rectangles in plugins API
* ♻️ Reuse per-side stroke restriction check across plugin API
Move the per-side stroke width restriction into the public
`per-side-stroke-attrs-restricted?` helper, now taking `page-id`, and reuse
it from both `shape.applyToken` and the token application path. Keep the
error message in a single `per-side-stroke-widths-message` def used by all
three rejection sites.
Wait for token resolution in the uniform stroke width regression test
instead of relying on a bare `settle`, which could assert before the
applied token lands.
AI-assisted-by: deepseek-v4.1-flash
---------
Co-authored-by: Luis de Dios <luis.dedios@kaieldos.net>
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.
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>
* 🐛 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
* ♻️ 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 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
* ✨ 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>
* ♻️ Explain why layout_container.scss keeps the deprecated import
The TODO at the top said to remove the refactor/ import once the buttons
were replaced, and sat above the DS imports instead of that one. The
buttons are now DS buttons (#12428, #12431), but the import is still
needed by the numeric inputs (%input-element, %disabled-input) and by the
two raw track header buttons, which keep the secondary and tertiary
placeholders. Replace the TODO with a comment on the import that says so.
AI-assisted-by: claude-opus-5-5
* 📎 Format the workspace visual spec with Prettier
Three expectations added in #12423, #12428 and #12431 broke a call chain
in a way Prettier rewrites. check-fmt:js did not catch it: its
playwright/**/*.js glob is expanded by the shell without globstar, so it
does not reach visual-specs/.
AI-assisted-by: claude-opus-5-5
* ♻️ Use a DS icon button for the component pill menu
Replace the raw three dots button of the component pill with the DS
secondary icon button. It had no label, so it reuses the "Options" one,
shown in the DS tooltip above and to the left (it sits at the right edge
of the sidebar), and aria-pressed while the menu is open instead of the
selected class.
Its class drops the button placeholders and only keeps the pill segment
shape: full height and square corners on the left. The tooltip wrapper
fills the segment too.
Add a visual test that selects a component copy so the pill shows.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS icon buttons for the component annotation actions
The save/create, discard, edit and delete actions of a component
annotation were divs with a click handler: no keyboard focus and no button
role. Replace them with DS ghost icon buttons. Their title moves to the
label, shown in the DS tooltip above and to the left, and save is disabled
while the text is blank, which it already ignored.
The class that hides them until the title is hovered, or while editing or
creating, now goes on the tooltip wrapper. The green and red hover colors
and the fixed size go away with the DS look. While editing or creating,
the actions get a small padding so their hover background does not touch
the annotation border; the title row grows from 32 to 36px there.
Add a visual test that creates an annotation on a main component and
shows both sets of actions.
AI-assisted-by: claude-opus-5-5
* 💄 Open component options tooltips above and to the left
The variants help, create variant, add property and remove property icon
buttons sit at the right edge of the sidebar: open their tooltips above
and to the left, like the other actions of the component options.
AI-assisted-by: claude-opus-5-5
* ♻️ Move component options styles off the deprecated styles
Inline the dropdown placeholders of the component pill menu with DS
tokens and a local variable for the item colors that change on hover, and
give the item label its own class instead of styling the span. Resolve
the annotation title border alias to its DS token and delete the unused
.component-title-actions rule.
The item label uses the DS body-small typography; its line-height is 1.3
instead of 1.4, with no visible change since the item centers it.
component.scss no longer imports refactor/.
AI-assisted-by: claude-opus-5-5
* ♻️ Use a DS icon button for the guide defaults menu
Replace the raw three dots button that opens the use default / set as
default menu of a guide with the DS ghost icon button. It had no label, so
it gets a new "Guide defaults" one, shown in the DS tooltip, and
aria-pressed while the menu is open instead of the selected class.
The add, show/hide and remove guide buttons, and the new one, sit at the
right edge of the sidebar: open their tooltips above and to the left.
The advanced options toggle stays a raw button: it is one segment of the
guide type control and an icon button would break that shape.
Add a visual test that adds a guide to a board and opens its advanced
options.
AI-assisted-by: claude-opus-5-5
* ♻️ Remove guide defaults button styles
The three dots guide defaults button is a DS icon button now: delete the
.show-more-options rule, its state variables and its svg rule. The rest of
frame_grid.scss already uses DS tokens.
AI-assisted-by: claude-opus-5-5
The exception page compared the screen query param against literals
that never occur ("view", "dashboard"), so viewer and dashboard urls
of inaccessible files and teams fell through to the plain not-found
page instead of the request access modal. Classify the param with a
tested helper that recognizes "viewer" and every "dashboard-*" route.
Closes#11224
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Use a DS button for edit grid in the grid cell options
Replace the raw "Edit grid" button of the grid cell options with the DS
secondary button. Drop its alt attribute, which is not valid on a button;
the label already names it.
The column and row icons label the numeric inputs, so they move with the
numeric inputs migration.
Add a visual test that selects a cell in grid edit mode so the options
show.
AI-assisted-by: claude-opus-5-5
* ♻️ Move grid cell options styles to DS tokens
The edit grid button is a DS button now, so its button placeholder and
typography mixin go away; the class only stretches it and centers its
label. Merge the two .grid-cell-menu-container rules, inline the flex
mixins, and move spacing, borders and color aliases to DS tokens and
logical properties.
Use the DS body-small typography in the inputs; its line-height is 1.3
instead of 1.4.
The refactor/ import stays only for %input-element and %button-icon, used
by the inputs and their legacy icons, which move with the numeric inputs
migration.
AI-assisted-by: claude-opus-5-5
* Revert "🐛 Fix shortcuts with modifiers on letters and digits (#12292)"
This reverts commit 21ecfb67b70ac2d6f4defb05a543f0fc18d692a2.
* 🐛 Migrate mousetrap to key with sane modifiers
Finish the half-done mousetrap migration from code/keyCode to
key: canonical lowercase tokens on both bind and event sides so
modifiers match again, US-only shift remap and keypress legacy
removed, and AltGr phantom ctrl filtered on Windows.
Record punctuation positionally through getLayoutMap (American
fallback) so Shift+Period stores shift+. on every layout, while
letters and digits stay glyph-based for AZERTY. Drop dead 219
aliases and revert the stray CamelCase escape command.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Rewrite mousetrap as ESM class without behavior changes
Turn the 2012 constructor-plus-prototype shape into a class
with underscore-private methods, const tables and arrow
listeners. Drop dead _belongsTo, fold one-line prevent/stop
helpers into _fireCallback, and let handleKey absorb the old
_handleKey indirection. Public API and matching logic stay
identical; verified by existing tests plus a runtime smoke.
AI-assisted-by: muse-spark-1.3-contributor
* 🎉 Add node:test suite for mousetrap shortcuts
Cover canonical key normalization, modifiers with AltGr,
bind arrays plus aliases keyup reset overwrite, sequences
with fake timers, and the full stopCallback matrix, all
through isolated instances and synthetic events. Hook the
zero-dependency suite into frontend/scripts/test for CI.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Record shortcuts by glyph instead of position
Drop the getLayoutMap positional layer and the
American fallback: punctuation now records event.key
lowercased, the same token Mousetrap dispatches, so
Shift combos match with and without a layout map.
Keeps the Numpad numN branch both sides share.
Updates the pinned positional tests and adds the
Spanish Shift-plus regression.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Resolve shifted digits to the digit itself
Shift+DigitN now resolves to the digit on every layout, so
shift+0 fires on QWERTY ()) as well as AZERTY (0), like the
old keyCode behavior. Applies in the recorder and Mousetrap;
unshifted digits and numpad are untouched. Also stages the
layout-map suite left untracked.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Cover punctuation defaults with layout twins
Some defaults only fired where their glyph is unshifted (+ dead
on US layouts, shift++ dead outside ES/DE, \ dead where it
needs AltGr). Bind the twin token per layout family and freeze
the display with :show-command. No tooltip or translation
changes.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Warn when the keyboard layout API is missing
Shows a warning on the shortcuts settings page in browsers
without navigator.keyboard.getLayoutMap, where Shift with
punctuation may not fire. Uses a synchronous probe, English
and Spanish strings, and documents the layout limitations in
the user guide.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix hide-ui twins and qualify layout docs
The ctrl+alt+\ twin could never fire (the AltGr filter drops
Ctrl), so it goes away; alt+º covers Chrome on Spanish
layouts where AltGr+º resolves to º. The user guide now
states which claims need the layout map and which hold
everywhere.
AI-assisted-by: muse-spark-1.3-contributor
---------
Signed-off-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
* ♻️ Use the DS icon in the workspace left header
The shared library badge of the left header used a deprecated icon; use
the DS library icon instead.
Drop the deprecated icons inside the save status dot. They rendered as
500px SVGs with no stroke, so they never showed: the dot is only its
background color, and its title keeps the status text. The Penpot logo is
brand art and stays.
Add a visual test that opens a shared file and shows the header with the
badge, then the saved status after drawing a shape.
AI-assisted-by: claude-opus-5-5
* ♻️ Move workspace left header styles off the deprecated styles
Inline the flex and ellipsis mixins, move spacing, sizes and borders to
DS tokens and logical properties, and resolve the color aliases to their
DS tokens. The status dot takes its color from a local variable. The
badge icon gets its color through a class instead of an svg selector.
The project name uses the DS headline-small typography: 12px instead of
11px. The file name matches title-small exactly.
Left with a TODO: the rename input font size and z-index have no DS
match, and the pending status orange (#f5a91b) has no DS color.
left_header.scss no longer imports refactor/.
AI-assisted-by: claude-opus-5-5
* ♻️ Resolve the TODOs left in the workspace left header styles
The status dot used the legacy warning orange #f5a91b, which drifted from
the DS one when both replaced #fe4811 a few days apart in early 2025. Use
the DS --color-accent-warning (#fe9c07), the orange of every other DS
warning, so the dot looks a little more orange while saving.
The rename input had z-index 20, which did nothing: the input is neither
positioned nor a flex or grid item. Remove it.
The rename input only set its font size and got the rest from the global
reset; it now uses the DS title-small typography, like the file name. Its
line-height goes from normal to 1.2, so it grows from 16 to 16.8px.
AI-assisted-by: claude-opus-5-5