Commit Graph
4092 Commits
Author SHA1 Message Date
Trevor BrownandClaude Fable 5.1 fe18f6d96d M6: .jam import: a connector end with an AUTO or CENTER magnet faces the other end (#1909)
An end whose magnet is AUTO has no fixed point: FigJam picks the side when it draws, and the file
keeps whatever relativePosition the end last had, so the importer sent every automatic end out of
the stale side (the left one in the reporter's file). AUTO and CENTER ends now attach to the side
facing the other end, as a board connector drawn here does, for straight, elbowed and curved
connectors alike; named magnets and ends without one are unchanged.

Test: connectors_with_an_automatic_side_leave_the_side_facing_the_target (the fixture schema gains
the ConnectorMagnet enum). Docs: file-format-parity .jam row, status line and revision history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 09:48:51 -05:00
Dylan M. Taylor 1206c0156e fix(format): charge an archive member's unpacking even when the read fails
The shared unpacking total (and Sketch's) was debited only when a member
read succeeded, so a snapshot or page member damaged near its end could be
named again and again by an index and unpacked each time without ever
spending the budget. A failed read is now charged its recorded size; one
refused for that size before any unpacking still costs nothing.
2026-11-03 14:41:56 +00:00
Trevor BrownandClaude Fable 5.1 be04efc4fb M10: embeddable script faces as document fallbacks; the Arabic UI with Arab embedded (#1902)
Review: Noto Sans Hebrew, Devanagari and Georgian join Arabic and Thai in FALLBACK_FAMILIES, so a
web build that embeds one draws that script in an Inter document; with Arab embedded the picker
offers the Arabic UI catalog, as cjk::can_show intends (the docs said otherwise); the docs
example lists one tag per script.

Test: every_embeddable_script_face_is_a_document_fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 09:41:15 -05:00
Trevor BrownandClaude Fable 5.1 14431850ca M1: Layers panel as wide as its widest name, scrolling sideways (#1904)
The Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so
an imported component set's variants all read "Size=small, Type=prim..." and a deeply nested name
lost its room to the indent. The row cache now also keeps the width the widest row needs (a few
names measured per rebuild, picked by a bound on their width), the list takes that width and
scrolls both ways, revealing a layer scrolls up or down only, and a name past 1200 pt still
elides (#1650).

Tests: layers_wide_names_tests. Docs: ui-parity Layers panel row, status line and revision
history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 09:34:08 -05:00
Dylan M. Taylor 37803cb1c8 perf(format): index the lookups that made style-heavy files quadratic
REST import searched every style imported so far for each layer's style
reference; .fig export compared each text span's style with every distinct
style before it; Penpot token-set ordering searched the whole order array
for every comparison of the sort. Each now looks up a set, a map or a
position table built once.
2026-11-03 14:30:05 +00:00
Trevor BrownandClaude Fable 5.1 89d411d50f M1: Layers panel component rows in the theme's component tint, at AA contrast (#1903)
The Layers panel drew component, variant and instance names and icons in the canvas outline
purple whatever the theme (3.1:1 on the dark panel, 2.7:1 on the selected row), and the rows
inside a selected component in the plain selection's green. They take the theme's component
token (the dark theme's lilac; the light theme's a shade darker than the canvas purple, so it
reads at AA on a selected and a hovered row too), and a new component_row_child token paints the
rows inside a selected component in a tone of its row. The canvas keeps its purple.

Test: layers_component_rows_tests (both themes). Docs: ui-parity themes row, status line and
revision history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 09:29:49 -05:00
Trevor BrownandClaude Fable 5.1 1354c6bb6f M10: FIGCRAFT_EMBED_SCRIPTS embeds other scripts' craft-fonts faces in a web build (#1902)
The browser has no system fonts and the text crate's build script keeps the web catalog to Inter,
Noto Sans Georgian, the wordmark and (with FIGCRAFT_EMBED_CJK) Noto Sans CJK, so craft-fonts'
Noto Sans Arabic never reached a web build and Persian or Arabic text drew as missing-glyph
boxes. FIGCRAFT_EMBED_SCRIPTS names script tags as the manifest lists them (Arab, Hebr, ...);
a web build keeps the faces of those scripts as well. The default build is unchanged.

Test: web_embeds_other_scripts_faces_only_when_asked (build script). Docs: localization-parity
(web fonts paragraph, Arabic row, status line, revision history), translating.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 09:23:44 -05:00
Trevor BrownandClaude Fable 5.1 bb7d568d49 M2: baseline alignment puts a layer without text on the line with its bottom (#1825)
In the baseline branch of the flow layout only an item with a text baseline got one; any other
layer stayed at the top of the line. A layer without a text baseline now uses its bottom edge, as
the help center's figure shows ("the bottoms of the icon and the word home are aligned on the red
line"): an icon next to a text sits on the text's baseline, a layer reaching below it moves the
text down, and a line with no text lines the layers' bottoms up (CSS does the same).

Tests: alignment_rules (three cases), baseline_alignment_horizontal, engine
baseline_alignment_puts_an_icons_bottom_on_the_text_baseline. Docs: auto-layout-parity row,
status line and revision history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 09:13:19 -05:00
Dylan M. Taylor 35684eaa02 fix(format): index kiwi field lookups and bound schema and empty-array growth (#1896)
A .fig message looked up each tag's field with a linear scan of its
definition, so a wide schema and many tags cost their product. Schema
decoding capped fields per definition but not in total, and inflated the
schema chunk under the 512 MiB message cap. Empty non-byte arrays spent
nothing from the decoded-value budget while still growing the output.

Field ids (and enum values) are now found through a per-definition map
built on first use, keeping first-match semantics. A schema holds at most
200 000 fields together and its chunk may inflate to 16 MiB. Every array
field spends one value for itself before its elements.
2026-11-03 06:09:08 -05:00
ljkx 692c4545ba M4: weight steps read Univers numbers (45 Light, 57 Condensed) by weight within their width (#1831) 2026-11-03 11:41:15 +01:00
Carbocode d68a140755 M8: remove parity documentation changes 2026-11-03 10:42:02 +01:00
Carbocode 901bc6d4fc M8: compute layer selection roots once per visible row 2026-11-03 10:39:24 +01:00
Carbocode ee6ca15ccf M8: join and round layer hierarchy highlights 2026-11-03 10:38:54 +01:00
Hamza 58af1a6237 M4, #1747: a text layer's name follows the characters typed into it 2026-11-03 12:41:29 +05:00
Hamza 6cb724c588 docs: credit Hamza Naveed in contributor names 2026-11-03 12:03:42 +05:00
Trevor BrownandClaude Opus 5.5 564dbcfe52 Merge upstream/main into fix/scrub-speed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:39:16 -05:00
Trevor BrownandClaude Opus 5.5 bc140f51cd Merge upstream/main into fix/text-mixed-fill-section
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:38:49 -05:00
Trevor BrownandClaude Opus 5.5 1496b8028c Merge upstream/main into fix/place-crosshair
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:38:48 -05:00
Trevor BrownandClaude Opus 5.5 1c26c03e2f Merge upstream/main into fix/field-alt-scrub
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:38:48 -05:00
Trevor BrownandClaude Opus 5.5 a3cadb66cf Docs: one vector edit row after the merge
The merge with main kept both versions of the row; they are combined, the Bend click sentence and the region fill sentences together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:12:47 -05:00
Trevor BrownandClaude Opus 5.5 a22d8b1f9e Merge upstream/main into feat/region-fills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:12:17 -05:00
Trevor BrownandClaude Opus 5.5 b936b95fd9 Stack on #1857: merge fix/text-mixed-fill-section
Both redirect the Fill section's reads and writes: the selected regions of a vector come first (#1878), else a text's characters' shared fills (#1857), in the Fill section, the colour picker, the canvas handles, fill.copy, fill.set and fill.move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:12:08 -05:00
Trevor BrownandClaude Opus 5.5 4671402df5 Stack on #1854 and #1869: merge fix/control-arrows-pan
Pick select changes the same ui_key and key_event paths as the crosshair and the pan; stacked on them as the maintainers asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:55:16 -05:00
Trevor BrownandClaude Opus 5.5 5851d9b978 Control channel arrows: test the crosshair before the pan
With a shape tool and nothing selected, ui.key ArrowRight moves the placement crosshair and leaves the view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:54:34 -05:00
Trevor BrownandClaude Opus 5.5 66a98b108b Merge upstream/main into fix/padding-all-sides
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:47:59 -05:00
Trevor BrownandClaude Opus 5.5 8c028c1ace Stack on #1855: merge fix/field-alt-scrub
Both change number_field_impl's click and drag handling; stacked so the combination is reviewed and tested together, as the maintainers asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:47:39 -05:00
Trevor BrownandClaude Opus 5.5 e884e3373a Merge upstream/main into fix/control-arrows-pan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:46:51 -05:00
Trevor BrownandClaude Opus 5.5 dd2a7fe63f Stack on #1869: merge fix/place-crosshair
The control channel's arrows: with a shape or frame tool and nothing selected they move the placement crosshair (#1869), otherwise with nothing selected they pan the view (#1854).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:46:37 -05:00
Trevor BrownandClaude Opus 5.5 84a0693076 Fill section: the canvas crop and gradient handles follow the characters' shared fill
The handles read the paint list the Fill section and the colour picker show (character_fills), so with the characters' image already in Crop mode the crop handles show and edit it, not the layer's own fill at that index; writes still go through fill.set, which moves the characters' list to the layer first. No undo step on entering crop mode. Test canvas::handles::character_fill_tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:45:53 -05:00
0e86f3597a M4: wrap style on a text layer: Auto, Balance and Pretty decide where its lines break (#1827) (#1871)
* M4: wrap style on a text layer: Auto, Balance and Pretty decide where its lines break (#1827)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Docs: no table pipe inside the wrap-style row's code span

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Wrap styles: no new emergency breaks, hanging punctuation in Pretty's width, wrap style as an override

Balance and Pretty reject a re-break with more mid-word breaks than the greedy lines; Pretty narrows a line by its text less its hanging marks; wrapStyle joins the textStyle override group; docs wording. Tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:13:48 -05:00
a579ba2d3c M8: a click with the Bend tool curves a sharp point, its handles along the line between its neighbours (#1875) (#1877)
* M8: a click with the Bend tool curves a sharp point, its handles along the line between its neighbours (#1875)

A release without movement on a sharp point between two segments runs ops::auto_smooth instead of cancelling; a curved point still sharpens, a drag still sets the handles by hand, an endpoint is left alone. Tests; docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Vector edit hit test: the nearer of a handle's end and a point wins

A short path's handles end within the point's hit tolerance; a click on the point is on the point, one on the handle's end still takes the handle. Test short_handles_leave_their_point_clickable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Vector edit hit test: the nearer of a handle's end and a point is judged on the page

Candidates stay within the tolerance as before; ranking by page distance keeps a non-uniform scale from favouring the wrong one. Test a_handle_and_a_point_are_compared_on_the_page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Vector edit hit test: a handle wins a tie with a point, as before

A handle snapped onto another point stays selectable there. Test a_handle_on_another_point_is_still_the_handles (fails with the strict comparison).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:13:38 -05:00
a7a0d12a85 M7: a file tab's menu and the board bar's shape menu read in the light theme (#1890)
* M7: a file tab's menu and the board bar's shape menu read in the light theme

Both open on the theme's popup, white in the light theme, but painted what
is in them with `menu_text`, which is white in both themes because it
belongs to the dark in-app menus (#972 was the same mistake in the panel
dropdowns):

- A file tab's right-click menu drew Open in new window with the menu
  bar's row (`menus::item`), so it showed only while hovered. It now uses
  `right::context_popup` and `right::menu_item`, like the tab strip's
  overflow menus: the theme's text, white on the green highlight.
- The board bar's shape menu outlined every shape but the picked one in
  white. They now take the theme's icon colour, white only on the green
  highlight.

The in-app menus (main menu, right-click on the canvas) stay dark in both
themes, as decided on #843.

Tests (menu_contrast_tests.rs): a file tab's menu opened with a right
click and the board bar's shape menu opened from its button, headless in
both themes: the label at 4.5:1 and every shape's outline at 3:1 against
the popup. Without the fix both fail at 1.00:1 in the light theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: contributor credits: a real name for aleksei-suholita

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Oleksii Sukholita <oleksii.sukholita@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:13:27 -05:00
c69eb55f31 M8: an ellipse whose start has gone round many turns keeps its sweep in .fig (#1891)
* M8: an ellipse whose start has gone round many turns keeps its sweep in .fig

The importer clamped the starting and ending angles to ±1000 rad each
before taking the sweep, so an arc starting near or past 1000 rad lost
its sweep: a whole ellipse starting at 999 came back sweeping 1 rad, one
starting at 1500 swept nothing. The sweep is now the difference of the
angles as written, and a start past a turn is brought into [0, 2π) the
way the renderer's sine and cosine see it. The .fig export writes the
start within a turn too, where a 32-bit angle still holds it and the
sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: an arc far from zero keeps its sweep rather than closing into a whole ellipse

Review follow-up on #1891. The whole-turn tolerance grows with the
angles, so once a start can lie far from zero it swallowed real arcs: an
arc of 6.1 rad written at 1e6 rad read as a whole ellipse, and at 3e7 rad
even a sweep of nothing did. The tolerance now only applies while it is
at most 1e-3 rad (about 0.06°), which holds up to a start of about 4,000
rad; further out the sweep is kept as written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: an arc with no end stays whole however far out it starts

Review follow-up on #1891. With no endingAngle the importer took the end
as start + 2π and the sweep as the difference, which far from zero
rounds away from a turn: from a start of 1e15 it came out 6.25, drawn as
an arc with its radius edges now that the snap to a whole turn is
bounded. No end now means a sweep of exactly 2π.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:13:17 -05:00
Dylan M. TaylorandBrandon Thomas 0eec582805 perf(format): drop dangling Penpot token aliases with a worklist (#1897)
drop_dangling_aliases rebuilt the id set and rescanned every token each
round, and an alias chain lost one link per round: quadratic in the
chain. Dependencies are now indexed once and invalidation propagates
through a queue, counting how many tokens make each variable id, so each
token is visited a bounded number of times with the same result.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:13:07 -05:00
Dylan M. TaylorandBrandon Thomas 8717728a87 perf(color): sort gradient stops once per diamond texture (#1894)
Gradient::sample cloned, sanitised and sorted every stop on each call,
and the diamond texture sampled it for each of its 65 536 pixels, so a
file's gradient with many stops cost that sort 65 536 times.

sample_sorted samples stops already sorted, finding the segment by binary
search; sample delegates to it and the diamond texture sorts once.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:12:58 -05:00
ad20c56874 Reject invalid dashes in node.set instead of dropping entries (#1899)
* Reject invalid dashes in node.set instead of dropping entries

Found while testing crates/engine/src/cmd/props.rs:580

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Reject boolean dash entries in node.set (review)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:12:49 -05:00
7d9d2bda50 Reject fractional index in layout.setTrack (#1892)
Found while testing crates/engine/src/cmd/layout.rs:46-48 (set_track)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:12:39 -05:00
033e943dc7 Reject fractional index in proto.interaction.remove and set (#1874)
* Reject fractional index in proto.interaction.remove and set

Found while testing crates/engine/src/cmd/proto.rs:37 (proto.interaction.remove) and :412 (set)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Name the upper bound in the interaction index error (review)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 00:12:29 -05:00
Dylan M. Taylor 3ebf0641cb fix(collab): place keyed-list puts through an index within a batch (#1893)
* fix(collab): place keyed-list puts through an index within a batch

Each Put of a comment, style, variable or collection scanned the whole
list for its key (cloning String keys on the way) before appending, so a
batch of N new entries cost N squared comparisons on the hub every
session shares. Removing an image also rebuilt the document's image-use
set before checking the image existed.

Replica::apply now keeps a per-batch key index for each keyed list; it
is rebuilt when the list's length or the entry at a key no longer
matches, and dropped on whole-list sets, reorders and real removals.
Removing an absent entry or image returns without scanning.

* fix(collab): keep the keyed-list index current through removals

Build the index without dead code outside tests, drop the wall-clock
assertion from the large-batch test, and shift the index down after a
single-entry removal instead of discarding it, so a run of removals in
one batch does not index the list again each time. Staleness checks for
puts and removals share one helper. Tests cover removals, duplicate keys
from a whole-list set, and absent keys.
2026-11-02 23:45:53 -05:00
Dylan M. Taylor 31a60ed360 fix(collab-net): give a management request one deadline for its answer
reply read until the server answered, and every ping, pong or control
frame renewed the per-read timeout, so a library endpoint could hold a
background refresh (and the shared refresh permit) open indefinitely. The
whole exchange now ends at one absolute deadline, the idle timeout from
when the request went out.
2026-11-03 04:35:27 +00:00
Dylan M. Taylor 421a8e5126 fix(doc): stop frame_relative on a looping parent chain
A restored dev snapshot can replace an ancestor with a historical
descendant while the root keeps that ancestor as its parent; frame_relative
walked the parent chain without a guard and never returned. It now stops
after more hops than the document has nodes, as is_ancestor does.
2026-11-03 04:34:50 +00:00
Dylan M. Taylor 72ec49f92f fix(engine): bound the keys an effect object is probed for
Unknown effect keys are found by reparsing the whole object once per key,
so an effect with many unknown keys cost their number squared in cloning
and parsing before it was refused. More than 32 keys is refused outright.
2026-11-03 04:31:08 +00:00
Dylan M. Taylor bd28a9c19e fix(text): load at most 64 faces from a font collection given as bytes
A collection header names its faces in four bytes each, so a small upload
could declare any number of faces over the same tables; every one was
parsed and kept its own character map under the font database's lock. The
largest real collections hold a few dozen faces; 64 are loaded.
2026-11-03 04:29:07 +00:00
ljkx dd03165d79 M4: weight steps read number weights after a width too (#1831)
An off-list "Condensed 600" read as 400, since a number word counted as
a weight only at the start of the name: weightUp took Condensed 500 and
weightDown found nothing. A number from 100 to 1000 now counts in any
position, as W numbers do. Smaller numbers (a width grade, an optical
size: "Condensed 2", "Caption 12") are part of the width rather than a
weight, so optical sizes no longer step into each other.
2026-11-03 05:27:23 +01:00
Dylan M. Taylor 1ff0da1841 fix(figcraft): write the recovery copy's online note owner-only
The .online note beside a recovery copy holds the secret the client resumes
its identity with, but it was written with the default mode while the copy
itself was created 0600. Both now go through one owner-only writer, which
also narrows a note that already existed.
2026-11-03 04:26:03 +00:00
Dylan M. Taylor a9e66d2de9 perf(vectordrawable): keep warning names in a set
Each affected layer name was checked against every name already kept for
the warning, quadratic in the number of distinct names although only three
are ever shown. A set holds the distinct names; the first three stay in
order.
2026-11-03 04:24:32 +00:00
Dylan M. Taylor 5bbab0fa18 fix(pdf): drop an oversized hyperlink before it is copied once per line
Every linked line copied the destination string, and its length was only
checked when the annotation was written, so one multi-megabyte link on text
wrapping onto many lines was copied that many times first. The length is
checked where the link areas are collected.
2026-11-03 04:23:05 +00:00
Dylan M. Taylor 970a3e6745 fix(engine): check the opened file, not the looked-up path, before reading it
read_capped checked the path's metadata (type and size) and then opened
the path again, so an entry swapped between the two steps was read without
those checks. The opened handle's metadata is checked as well; the path
check stays, so a pipe is still refused before it is opened.
2026-11-03 04:22:36 +00:00
Dylan M. Taylor 4e7d8319f1 fix(collab-net): hold a health check's header read to the handshake deadline
The request line was read within the hello timeout, but the header loop
after it only timed out per read, so a peer dripping header bytes kept the
worker thread and its connection slot for as long as it liked. Each header
read now gets what is left of the one deadline.
2026-11-03 04:22:15 +00:00
Dylan M. Taylor 8864068cd5 fix(engine): compare online servers' paths with their case
same_server lowercased the whole address, so files on /Team/ and /team/
(which a proxy can route to different servers) counted as one server and
the online refresh sent both files' owner tokens to whichever answered.
The scheme and host still compare without case; the path keeps its.
2026-11-03 04:21:45 +00:00