Commit Graph
1537 Commits
Author SHA1 Message Date
FractalEncrypt a6c0911d72 G1: Honor artwork scaling in Artboard Options size changes 2026-10-23 04:48:28 +07:00
1e45fe99f7 i18n(ka): Relative Colorimetric is ფარდობითი კოლორიმეტრიული
The adjective from კოლორიმეტრია is კოლორიმეტრიული; ფარდობითი matches PhotoCraft
(storytold/photocraft#5845, from a review by supersfera-online).

Co-authored-by: supersfera <223132313+supersfera-online@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-23 04:01:58 +07:00
skylar taylor-barrick 2e142583b3 #2278: test that the Properties flip buttons keep the reference app's names
The Properties panel's flip buttons had been renamed Flip Horizontal and Flip Vertical, which are the Transform panel menu's names. The test lays out the Transform section for a selected rectangle and reads the screen-reader names of its buttons: Flip Along Horizontal Axis and Flip Along Vertical Axis must be there and the old names must not. With the old labels the first is missing.
2026-10-23 03:33:30 +07:00
skylar taylor-barrick 02f5bc8666 #3533: test that a popover stays open through its anchor click and a panel drag
The Fill/Stroke chip's popover closed on the click that finished a drag between its proxies. The test opens a popover and checks the three outcomes: a click elsewhere closes it, a click on its own anchor that does not toggle it keeps it open, and a click elsewhere during a panel drag keeps it open. Without the fix both of the last two close it.
2026-10-23 03:33:30 +07:00
skylar taylor-barrick ae6cf1a729 #2083: test that panel menu rows light up under the pointer
The rows of the panels' menus did not light up under the pointer as the menu bar's items do, because they were drawn with no frame at all. The test hovers a menu_item_name row and checks that a filled rectangle appears behind it, and that none is there at rest. With frame(false) the hovered row stays unfilled.
2026-10-23 03:33:30 +07:00
skylar taylor-barrick a65cae367f #1506: test that sliders show their rail filled up to the handle
The theme's widget fill matched the dialog background, so a slider in a dialog showed only its handle. The test draws widgets::slider in all four brightnesses and checks that a rail in the buttons' border colour and an accent fill from the rail's start up to the handle are painted. With a plain egui slider neither is drawn, only two rectangles in the panel's button fill.
2026-10-23 03:33:30 +07:00
skylar taylor-barrick f39c9544eb #1490: test that selected text in value fields is in the strong text colour
Selected text in a value field was drawn in the accent on a selection tinted with the same accent, hard to read in every brightness. The tests focus a panel number field and a dialog text field, select their text and read the colours of the glyphs drawn, in all four brightnesses: all must be the strong text colour. Without strong_selected_text the glyphs come out in the accent.
2026-10-23 03:33:30 +07:00
skylar taylor-barrick b5a99542eb #916: test that a spinner field reads as one box in three parts
A number field with a spinner and preset chevron drew as three separate boxes, each joint with two borders. The test lays out a spin field and checks that its three boxes are the row's height and that neighbours overlap by one point so they share a border. Without the overlap (item spacing 0) the boxes touch edge to edge instead.
2026-10-23 03:33:30 +07:00
skylar taylor-barrick 888af39881 #815: test that the toolbar's scroll bar stays a slim line
The toolbar's and the dock icon column's scroll bar widened to 10 points while the pointer was on it, covering the group labels and the tool icons' right edge. The test lays out a scrolling strip, rests the pointer on its bar and measures the widest filled rectangle drawn: at most 4 points. Without the slim scroll style it measures 10 points.
2026-10-23 03:33:30 +07:00
skylar taylor-barrick 3f74e38fea #871: test that the Artboard tool names its active artboard once
With the Artboard tool the active artboard's name was drawn twice: by the canvas, as for every artboard, and by the tool, a little higher. The test draws a frame with two artboards and checks that each name is painted once and that the active artboard's sits where the other's does. It fails without the canvas skipping the tool-labelled artboard (two labels for the active one) and without the tool's label position (8 points to the right and 11 higher than the other name).
2026-10-23 03:33:30 +07:00
skylar taylor-barrick 548fad4b59 #480: test that the Add and Delete Anchor Point tool icons are the Pen's icon with a badge
The Add and Delete Anchor Point tools' icons were drawn apart from the Pen tool's, so the three didn't read as one family. The test checks that both tools' icons contain every shape of the Pen tool's icon plus a badge of their own, and that plus and minus differ. With the tool mapping pointing back at the old dc-pen-add and dc-pen-delete icons it fails: the Add tool's icon lacks the Pen's outline.
2026-10-23 03:33:30 +07:00
bflatastic 8bf7ffe39d Snapping: tangent and perpendicular snapping, View > Snapping (#2530)
Squashed from #2537 onto main.
2026-10-23 03:22:54 +07:00
makesomethingshit 6a394c3a6f #3607: follow the active artboard with every tool 2026-10-23 01:53:47 +07:00
makesomethingshit 4d9749f4c7 #3607: open Save for Web on the active artboard 2026-10-23 01:53:35 +07:00
bflatastic efe2429618 Shape Builder: in merge mode, a click on a stroke splits the path there
Squashed from #3665 onto main.
2026-10-23 01:53:34 +07:00
Taha-Mahmoodi 6beb68f934 Gradients: the Gradient panel's Method menu and paint.editGradient's method choose Classic or Perceptual
Illustrator 30.0's Method choice in the Gradient panel
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html).

- paint.editGradient takes `method: classic|perceptual` (one undo step);
  paint.setFill/setStroke take it in `gradient`, and gradient params (swatches,
  copied paints) carry it when Perceptual. Anything else is refused without
  editing. One edit can set `method` and `dither` together.
- The Gradient panel shows a Method menu (Classic, Perceptual) for linear and
  radial gradients, dimmed without a gradient; the Dither box stays under the
  stops.
- i18n: "Perceptual" where a complete catalog lacked it, and the `method`
  error in all 24 catalogs.
- Docs: G13 (estimate 14–24 h → 13–23 h, after Dither's 15–25 h → 14–24 h),
  target-app-parity (row and evidence), ui-parity, mcp.md, plugins.md.
2026-10-23 01:53:30 +07:00
BFlat 7fd4b33dc5 Merge pull request #3670 from dotCooCoo/fix/batch-revert-keeps-the-view-artboard
Artboards: an action or Batch… that reverts or switches documents keeps the active artboard
2026-10-23 00:46:58 +07:00
BFlat 9b2173611f Merge pull request #2975 from nrq6993-a11y/stream/vc-feat3-g12-low-res-proxy-eps
G12: honor Use Low Resolution Proxy for Linked EPS (#394)
2026-10-23 00:37:02 +07:00
BFlat b50fbc08cf Merge pull request #3668 from Taha-Mahmoodi/fix/dither-followups
Gradients: dithered fills sample only what shows; review follow-ups to #3637
2026-10-23 00:23:42 +07:00
dotCooCoo 44c306f142 Artboards: an action or Batch… that reverts or switches documents keeps the active artboard
With the Artboard tool, File › Revert kept the active artboard, but a
command.batch with a file.revert step made artboard 1 the active one.
A batch that switched to another document and back, and the Actions
panel menu's Batch… over a folder whose files it closes or over data
sets with a Revert step, did the same. Each of them starts the tool
afresh and ends in the document it started in, and the app moved the
view to the fresh tool's artboard 1. Only a plain file.revert was
handled, by its command id.

The engine now counts the times switching or closing documents, or
replacing the active one, starts the tool afresh (Session::tool_resets),
and records whether the tool's options were set after that
(Session::tool_set_since_reset). When the count changes during a
command and nothing set the tool's options after it, the app starts
the tool on the active artboard, as it does when a command chooses
another document. When a later step chose the tool's artboards (a
Paste or Cut after the revert), the artboard the tool then has active
becomes the active one.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-23 00:20:54 +07:00
bflatastic 30360a67a0 G12: honor Use Low Resolution Proxy for Linked EPS (#394)
Squashed from #2975 onto main.
2026-10-23 00:14:52 +07:00
BFlat ab330e4adc Merge pull request #3649 from nrq6993-a11y/stream/vc-feat3-g4-mixed-hyphenation-dialog
G4: Mixed Hyphenation dialog fields
2026-10-23 00:10:09 +07:00
BFlat dc38eb6bee Merge pull request #3648 from nrq6993-a11y/stream/vc-feat1-g4-mixed-paragraph-direction
G4: Mixed paragraph direction leaves direction buttons unselected
2026-10-23 00:09:51 +07:00
Taha-Mahmoodi 7c9edfaf7f Gradients: an outlined stroke leaves dither behind, and paint.setStroke refuses it
Review follow-up (CodeRabbit): paint.setStroke kept `dither: true` from its
gradient, and a stroke can hold dither anyway (swapped from a fill). Outline
Stroke and appearance expansion copied the stroke's paint into the outline's
fill, which then drew dithered although the stroke never did. The outline's
fill now goes without dither (Paint::stroke_as_fill), and paint.setStroke
refuses a gradient with `dither: true`, as paint.editGradient does.
2026-10-23 00:08:53 +07:00
bflatastic 329916c2b8 G4: Mixed Hyphenation dialog fields
Squashed from #3649 onto main.
2026-10-22 23:46:14 +07:00
Cursor Agentandnrq6993-a11y 2f3ccb7b2c G4: mixed paragraph direction leaves direction buttons unselected
## Summary
When the Paragraph panel acts on more than one paragraph whose displayed
direction differs (explicit LTR/RTL or Auto resolved from content), neither
Left-to-Right nor Right-to-Left is highlighted, matching alignment and the
other mixed-value paragraph controls in Adobe Illustrator.

## Tests
- `paragraph_mixed_direction_buttons_show_neither` in `panels/paragraph.rs`

Part of gap G4

Co-authored-by: nrq6993-a11y <nrq6993-a11y@users.noreply.github.com>
2026-10-22 23:45:28 +07:00
dotCooCoo 4a4c8c9d49 Package: a file the writer refuses is named once in the error
File › Package… put the file's path in front of every error its writer
returned. A refusal from the automation write root already names the
path, so the dialog's error and the status bar named the file twice,
for example on Windows for a document named aux:
`<path>: automation path rejected: reserved device names are not
allowed: <path>`.

The dialog now keeps a writer error that already contains the path as
it is, and puts the path in front of one that does not, such as the
desktop's "Access is denied. (os error 5)".
2026-10-22 23:36:57 +07:00
BFlat 6e091a33f5 Merge pull request #3636 from MiQ71/fix-3447-spiral-dialog
Fix Spiral tool click options dialog
2026-10-22 23:27:46 +07:00
BFlat b35a6b1573 Merge pull request #3638 from dotCooCoo/fix/cmd-enter-shortcut-skips-the-tool-enter
Shortcuts: a command on Cmd+Enter or Cmd+Escape runs without the tool's Enter or Escape
2026-10-22 23:11:43 +07:00
bflatastic a99d81e9aa Fix Spiral tool click options dialog
Squashed from #3636 onto main.
2026-10-22 22:57:26 +07:00
dotCooCoo bd12b48e02 Shortcuts: a command on Cmd+Enter or Cmd+Escape runs without the tool's Enter or Escape
Enter and Escape went to the active tool whatever modifiers were held,
and the command bound to the chord then ran as well. With a command
bound to Cmd+Enter, Cmd+Enter ended the Pen tool's path, opened the
Move dialog with the Selection tool, or typed a line break while the
Type tool edited text, and then ran the command. With a command bound
to Cmd+Escape, Cmd+Escape ended the Pen path, the Type tool's text
editing or isolation mode before the command ran.

A press of Enter or Escape whose chord a command is bound to, matched as
the shortcut dispatcher matches it, now runs only that command, also
when the tool claims the key. While the Type tool edits text, only Cmd
chords count. While a tool is in the middle of an interaction (a drag,
or between the clicks of a tool such as Blend or Dimension), and for
plain Enter and Escape and chords that no command is bound to, the key
goes to the tool as before.
2026-10-22 22:44:58 +07:00
Taha-Mahmoodi cc1a660a6d Gradients: Dither in the Gradient panel draws a gradient fill with fine noise that hides banding
Illustrator 30.0's gradient Dither, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-dither-in-gradients.html):
a Dither checkbox in the Gradient panel; dither converts the gradient into
pixels with subtle noise, works with gradient fills only, and follows the
Document Raster Effects resolution.

- Gradient gains `dither` (serde default false, written only when on, so
  older files open unchanged and new files keep it).
- The renderer draws a dithered linear or radial fill as an image paint: the
  gradient sampled a cell per device pixel or finer (quarter-octave steps,
  cached per thread), each channel given seeded triangular noise of up to a
  level before rounding. Same pixels every run; strokes and undithered fills
  draw exactly as before. PNG and other raster exports go through it.
- SVG and PDF write a dithered fill as an image at the document's raster
  effects resolution, clipped to the shape (the freeform-gradient path);
  characters' fills stay vector gradients in SVG, with a warning.
- paint.editGradient and paint.setFill take `dither`; strokes refuse it.
  The Gradient panel's Dither box is dimmed behind the Stroke proxy.
- i18n rows in all 24 catalogs; docs: G13, target-app-parity, ui-parity,
  mcp.md, plugins.md.
2026-10-22 22:38:42 +07:00
Taha-Mahmoodi 4bb6f32911 Gradients: built with Gradient::new, so a new option touches one place 2026-10-22 22:38:41 +07:00
dotCooCoo 22b6f2a89c Place: Link's tooltip treats a PostScript .ai as a file it embeds
The Place dialog classed every .pdf, .ai and .ait path as a document
that Link places as one locked object. A .ai saved without PDF
compatibility is PostScript, and file.place embeds it as editable art
with Link on or off, so the tooltip was wrong for it. The dialog now
reads the file's header with the same PostScript check file.place uses,
and describes such a file as it does other files Link does not keep
linked.
2026-10-22 21:52:23 +07:00
dotCooCoo 9a72eadc51 i18n: Turkish row for Place's not-a-document message
Turkish is now a complete-message language, so the message file.place
gives for a file that is not a VectorCraft document, a PDF or a
PDF-compatible .ai file needs a tr row.
2026-10-22 21:52:23 +07:00
dotCooCoo 781e62b9cc Place: Link keeps a placed PDF or .ai page linked, as it does images
The File Handling preference Link Placed PDF and .ai Files
(linkPlacedPdf) is removed. File › Place with Link now places a page of
a PDF, .ai or .ait file read as PDF pages as a linked placed document,
as it links a raster image, and link: false embeds the page. Link is on
by default in the Place dialog and in file.place, so a PDF or .ai page
placed from a file, which used to be embedded, is now linked unless
Link is off. A password or textAs: "outlines" still embeds, and so do
EPS and PostScript .ai files.

The Place dialog's Link tooltip describes a PDF or .ai file as a
document.

A .ai file read from its editing data (its PDF part only a placeholder)
finds the files its art places by name, beside it. Package now copies
those files into the same folder as the .ai file, and warns when one has
to be copied under another name.
2026-10-22 21:52:23 +07:00
dotCooCoo 3a1d407f1a Place: a placed PDF or .ai can stay linked to its file (preference, off by default)
File › Place with Link kept a link only to raster images and VectorCraft
documents. A PDF, .ai or .ait page was always embedded as a group, so
Update Links never saw a newer version of the file.

A new preference, File Handling › Link Placed PDF and .ai Files
(linkPlacedPdf), is off by default. With it on, Link places the page as
a placed document, as it places a VectorCraft document: the object
keeps the file's bytes, a preview, the page number and the page's box
(crop, art, trim, bleed or media, stored as page_box). Links check,
update and relink it, Break Link and Object › Expand give the group
placing it without Link gives, every output draws the page's vectors,
and saving and reopening keep the link. With the preference off, or
with a password or textAs: "outlines", the page is embedded as before.
EPS and PostScript .ai files are still embedded.

Package copies a placed PDF or .ai file as it is. The Place dialog's
Link tooltip describes a PDF or .ai file as a document while the
preference is on. Relinking a placed document to a file that is not a
VectorCraft document, a PDF or a PDF-compatible .ai file says so.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-22 21:52:23 +07:00
bflatastic 1b7cfc8bee Automation: --automation-read-root and --automation-write-root may each be given more than once (#3546)
Squashed from #3631 onto main.
2026-10-22 21:36:15 +07:00
Taha-Mahmoodi a5778fcfca Effects: Conté Crayon's Foreground Level and Background Level are translated in every catalog 2026-10-22 21:18:35 +07:00
Taha-Mahmoodi bd6d076484 Effects: Sketch › Conté Crayon draws the dark areas in a black crayon and the light areas in a white one on textured paper 2026-10-22 21:18:34 +07:00
BFlat cee30f05b6 Merge pull request #3616 from devius/ka-update
i18n(ka): Georgian for 7 strings added since #2045
2026-10-22 19:30:14 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 2e2e6a2f8e i18n(ka): Georgian for 7 strings added since #2045
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-22 19:18:01 +07:00
Taha-Mahmoodi cc499d7e8e Fix #3370: each step stops at the bounds, so a step back in the same frame starts from there
Review follow-up (CodeRabbit): step_value clamped once after all of a
frame's steps, so at the lower bound a step down then a step up in the
same frame left the field at the bound. Each step is now clamped as it is
applied. The Stroke panel test steps down and up in one frame at each
bound and expects one step above it; it fails without the change.
2026-10-22 18:56:33 +07:00
Taha-Mahmoodi 28ac044c74 Fix #3370: stepping a number field stops at its bounds: a stroke weight at 0, a miter limit at 1
Wheel notches and the arrow keys over a focused number field step the
value its text shows (widgets::step_value), and nothing there knew the
field's bounds: the Stroke panel's Weight went on to -3 pt and its Limit
to 0 x in the field, while the panel only clamped what it applied (the
spinner's min, Limit's clamp to 1..500), so the next step went on from
the out-of-range text. A label scrub (scrub::field) had the same gap.

step_value and scrub::field now take the field's bounds and stop at
them, so the field shows where they stopped and the value applied is
the same. The fields that have bounds pass them:
- spin_field / spin_field_auto / spin_plain: their `min` (the spinner
  and a typed value already stopped there);
- range_field: its range; it now also takes a value that may be blank
  (None), as mixed_field does;
- opt_field (dash and gap lengths): 0;
- the Revolve dialog's values: their range.
Fields that clamped only what they applied now say their range with
range_field instead: the Stroke panel's Limit (1..500), the Transparency
and Properties opacity (0..100), the Transform panel's polygon sides,
and the dialogs' bounded counts, percentages and lengths (Blend Options,
Envelope, Flattener, Liquify, Freehand, Live Paint, Path ops, Perspective
Grid, PNG ppi, Print, Save for Web, Shape Builder, Slices, Swatch
Options, Symbol options, Justification, Dimension, PDF page, the effect
dialogs' number rows). docs/ui-parity.md's number-field row says so.

Test: panels::stroke::tests::the_wheel_stops_weight_at_zero_and_limit_at_one
(the Stroke panel in a headless frame; focus Weight at 1 pt and turn the
wheel down four notches: the field shows 0 pt and the stroke is 0 pt;
the same with Limit at 2 x: 1 x and a limit of 1). It fails without the
change (the field showed -3 pt) and passes with it; the other numeric
field, scrub and dialog tests still pass.
2026-10-22 18:56:33 +07:00
dotCooCoo cbf2d9cc43 Export: a typed extension of a format the export doesn't write is replaced
An export through the desktop save panel kept any typed extension
that names a known format, whatever format it wrote. Export As with
PNG chosen and card.svg typed wrote PNG data to card.svg, and Save as
PDF, Save for Office Documents, Text Export Options, Export Library
and the other writes that go through pick_path did the same.
document.exportSelection with a format suggested and added .png
whatever the format.

Such an extension is now replaced with the written format's, as Save
As has done since #3372: card.svg is written as card.png. An extension
of the written format stays as typed in any letter case (photo.JPEG
for JPEG), Export As SVG (and file.exportAs, file.export.svg) keeps
art.svgz and writes it compressed, and document.exportSelection
without a format still writes the format the typed extension names.
With a format, it suggests and adds that format's extension.

Docs: docs/target-app-parity.md.
2026-10-22 18:13:11 +07:00
dotCooCoo 052bbb14b4 Package: File › Package collects and writes the package on the background thread
File › Package… read every linked file and font and wrote the package
on the UI thread, so the window stopped responding until the package
was written. For a document that links 50 images of 21 MB each, OK
with Background Export off, which does the same work, takes a median
of 2.25 s in a release build on Windows.

With Enable Background Export on (Preferences › File Handling), OK now
takes the options, the report's Document Info text and a snapshot of
the document, and closes the dialog. The thread that writes background
saves and exports then collects the links and fonts and writes the
package. The same OK takes 0.001 s, and the package holds the same
bytes. A save of unsaved changes that Package starts is written first.
With the preference off, on the web and over MCP and scripts, the
package is made at once with the same reply as before.

Docs: docs/target-app-parity.md.
2026-10-22 17:20:48 +07:00
makesomethingshit e56e40dd85 M3.78: keep Recolor Edit chips within the screen (#3416) 2026-10-22 17:10:16 +07:00
dotCooCoo 5d4cac31cf Artboards: the active artboard follows the Artboard tool after Paste, Cut and tool.setOption
With the Artboard tool, Paste and Paste in Place select the pasted
artboards, Cut gives the tool the artboard before the first cut one
(the new first artboard when artboard 1 is cut), and tool.setOption
sets its selected or active artboard. The active artboard, the view's,
stayed where it was, so the navigator showed another artboard than the
tool and the Artboards panel. document.activate and File › Revert
started the tool on artboard 1, while choosing a document's tab starts
it on that document's active artboard.

After a command that changes the artboards the Artboard tool has
selected, the app now makes the tool's active artboard the active one,
as a click with the tool does. After a command that chooses another
document, and after Revert, the tool starts on the document's active
artboard.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-22 16:59:38 +07:00
Taha-Mahmoodi 7141932dfb Fix #3443: the Layers list's scroll bar no longer covers the rows' selection marks
The Layers list scrolls in its own scroll area, which kept egui's floating
scroll bar when #3093 gave the panel bodies a bar in a column of their own.
Hovering the list showed the bar over the right edge of the rows, over the
target circles and the selection squares the user clicks to select a layer.

The list now uses the menus' slim solid bar (list_scroll_style) in its own
column, and the rows end beside it. The list knows its total height before
it lays the rows out, so the bar is shown exactly when the rows overflow
(decided up front): the rows never shift sideways a frame after the bar
appears.

Test: dock::tests::the_layers_scroll_bar_covers_no_row (40 layers in a
1440x600 window: no row's accessibility rect intersects the bar). It fails
without the change and passes with it. The Layers tests that pin row
positions across frames still pass. Checked live: the bar sits beside the
rows.
2026-10-22 16:43:39 +07:00
Taha-Mahmoodi 6ee972c4da Fix #2452: panning or zooming over detailed art no longer hit-tests it every frame
With the pointer resting over art while the view moves (trackpad or wheel
scrolling, pinch zoom, a Hand/space pan), the document point under the
pointer changes every frame, and the canvas hit-tested the art there on
each one, up to three times: Object Highlighting (canvas.rs
hover_highlight -> selection_hit_at) and the Selection tool's cursor
(select.rs cursor: hit_test, and with Snap to Point the nearest-anchor
search). Over a detailed engraving in a clip group (the reporter's
PhotoCraft icon: one path of tens of thousands of segments) each of those
costs roughly 3-6 ms, so every frame of the move took 12-15 ms of UI time;
off the art they return at once.

- canvas.rs: the canvas notes when its view (rect, zoom, centre,
  rotation) last changed. Until it has been still for HOVER_STILL_SECS
  (0.15 s) it draws no hover highlight and keeps the cursor the tool last
  gave for the same tool and modifier keys instead of asking again; it
  asks for a repaint then, so both come back without the pointer moving.
  Pointer moves still reach the tool as before.
- test: a_moving_view_leaves_the_art_under_the_pointer_unhighlighted_until_it_stops
  (wheel frames draw no hover outline and keep the cursor; once still,
  the outline returns and the cursor follows the art). It fails on main
  at the first wheel frame.

Measured on an Apple M4 (release build, 1440x900 window, the PhotoCraft
app icon SVG fitted at 138%, Selection tool, Smart Guides and Snap to
Point on), median UI frame time over 60 wheel-pan frames with the pointer
resting:
  before: over the art 12.3-15.0 ms, off the art 1.0 ms
  after:  over the art 0.4-1.0 ms,  off the art 0.6 ms
The render readout is the art's own raster time on the render worker,
which this change doesn't touch.
2026-10-22 16:06:55 +07:00