Skip to content

Allow DOM event and style modifiers on SVG content - #119

Merged
sliemeobn merged 21 commits into
elementary-swift:mainfrom
Vithanco:svgview-modifiers
Sep 1, 2026
Merged

sliemeobn merged 21 commits into
elementary-swift:mainfrom
Vithanco:svgview-modifiers

Conversation

@Vithanco

@Vithanco Vithanco commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

SVG content composes but cannot respond to anything. Group, ForEach, Optional, _ConditionalContent and the tuples all carry extension …: SVGView where …, so you can build any SVG tree — and then nothing in it takes a handler or a style:

SVG.rect(.x(1), .y(2), .width(10), .height(11))
    .onClick { _ in }   // ✗ no such member
    .opacity(0.5)       // ✗ no such member

One constraint causes it. DOMEffectView is declared Wrapped: View, but its body uses only Wrapped.Tag and the _Mountable requirements. View means HTML & _Mountable, so asking for mountability also charges for HTML-ness.

The fix is what _KeyedView already does in KeyedView.swift — generic over MarkupContent & _Mountable, conditional conformance, modifier declared once per namespace:

extension DOMEffectView: HTML, View          where Wrapped: View {}
extension DOMEffectView: SVGContent, SVGView where Wrapped: SVGView {}

DOMEffectView was the one wrapper of its kind not yet converted. The mirrored declarations are the same duplication key(_:) pays: some View<Tag> and some SVGView<Tag> are distinct opaque types.

DOMEffectView was constrained to Wrapped: View, but nothing in its body uses
anything HTML-specific — only Wrapped.Tag and the three _Mountable
requirements. The modifier stack it drives attaches to DOM nodes, which are
namespace-agnostic, so the constraint was stricter than the implementation.

Relaxing it to Wrapped: MarkupContent & _Mountable and conditionally conforming
lets the same wrapper carry a modifier over HTML and over SVG alike. Only
SVG.svg conforms to View; everything nested inside it is SVGView, so before
this no inner SVG element could take a handler at all.

MarkupContent is stated once and unconditionally, because a type may conform to
a protocol only once and both HTML and SVGContent inherit it. Tag is the
wrapped tag, Body is Never, and the _render default on MarkupContent supplies
_Renderable, so it costs nothing.

The event modifiers are mirrored onto SVGView as a first slice. The duplicated
declarations are forced by the return type: some View<Tag> and some
SVGView<Tag> are different opaque types, so a single shared extension on
MarkupContent & _Mountable cannot serve both without making DOMEffectView
public API. Happy to go the other way if you would rather expose it.

Tests cover a handler on a nested element, the no-argument form, stacked
modifiers, that the wrapper leaves the wrapped element's ops untouched, and a
recursive SVGView carrying one handler per element.

Remaining if this direction is right: ~15 more modifiers across styles, focus,
binding, lifecycle, onChange, keyed views, transitions and global events.
@Vithanco Vithanco closed this Aug 13, 2026
The blocks above DOMEffectView and in SVGView+DOMEvents largely restated the
signatures they sat on. What is left is what the code cannot say: that the
loose Wrapped constraint is deliberate because DOM nodes are namespace-agnostic,
that MarkupContent must be unconditional since a type may conform only once, and
why the mirrored SVGView declarations cannot be shared.

The two per-test doc comments repeated their own test names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Vithanco Vithanco reopened this Aug 13, 2026
@Vithanco
Vithanco marked this pull request as draft August 13, 2026 20:54
Completes the two modifier families that `DOMEffectView` drives, so SVG
content is no longer interactively inert:

- events: onMouseDown, onMouseMove, onMouseUp, onKeyDown
- styles: opacity, rotationEffect, offset, scaleEffect (x2), blur,
  saturation, brightness

`onInput` is left out (input events do not fire on SVG elements), as is
the deprecated `Float` overload of `offset`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Vithanco Vithanco changed the title Allow DOM modifiers on SVG content Allow DOM event and style modifiers on SVG content Aug 16, 2026
Vithanco and others added 2 commits August 16, 2026 07:37
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Vithanco
Vithanco marked this pull request as ready for review August 16, 2026 06:00
@sliemeobn

Copy link
Copy Markdown
Collaborator

@Vithanco thanks for the PR!

I had it on the list to eventually fix this - so thank you for starting the work.

the only thing I am not sold on yet is weather the "full duplication" is really a good thing. I think there may be a cleaner option of using public return types (instead of some ...) for the event / lifecycle modifiers somehow (ideally without exposing all internals) and conditionally conform them to View / SVGView respectively.

then there may be a way to implement these extensions on MarkdownContent somehow only once.

wdyt?

@Vithanco

Vithanco commented Aug 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Spiked it — works.

Snag: DOM is @_spi(Benchmarking), so a public DOMElementModifier forces mount(_ node: DOM.Node, …) public and leaks the SPI type. Marking that one requirement. @_spi(Benchmarking) fixes it — all conformers are in-module. DOMEffectView then becomes nameable and the conditional conformances already here do the rest; SVGView+DOMEvents.swift disappears entirely.

One exception to "only once": onInput, focused and bindValue/bindChecked stay on extension View — they shouldn't reach SVG.

Vithanco and others added 2 commits August 22, 2026 18:56
Return the concrete DOMEffectView instead of some View<Tag> / some SVGView<Tag>,
so its conditional conformances pick the namespace and one declaration serves
both. SVGView+DOMEvents.swift is no longer needed.

Naming DOMEffectView in a public signature means DOMElementModifier has to be
public, which forces its mount requirement public too - and that names DOM.Node,
which is @_spi(Benchmarking). Marking just that one requirement
@_spi(Benchmarking) keeps the two in the same visibility tier. Every conformer is
in-module, so nothing outside can be affected.

onInput stays on View: input events do not fire on SVG elements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same treatment as the event modifiers: return the concrete DOMEffectView so the
conditional conformances pick the namespace, and drop SVGView+Styles.swift.
View+Styles.swift becomes MarkupContent+Styles.swift; its documentation moves
with it, reworded from "view" to "content" now that it covers both.

The offset(x:Float,y:Float) overload moves too. Splitting it from the Double one
would change overload resolution for integer literals on HTML.

This costs more public surface than the events did: naming the style modifiers in
a return type makes their Value typealiases public, which pulls in CSSOpacity,
CSSTransform and CSSFilter along with their nested function and payload types.

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

Copy link
Copy Markdown
Collaborator

I think this is the right direction, but the "public" blast radius is too big for my taste.

I was hoping we can get some clever ModifiedContent thing going, and maybe return a ModifiedContent<some Modifier, Self> without exposing too much - and hopefully still have a way to statically use the concrete types' implementation...

I am not 100% sure if this can be done with current Swift, but I have not found the time to explore a shape for this. Do you know where I am trying to go?

Naming the modifier in the return type was what forced the public surface open:
a public DOMEffectView<OpacityModifier, Self> makes OpacityModifier public, which
makes its Value typealias public, which pulls in CSSOpacity, CSSTransform,
CSSFilter and every nested function and payload type.

Returning DOMEffectView<some DOMElementModifier, Self> keeps the wrapper named,
so the conditional conformances still pick the namespace, while the modifier
itself stays hidden. Every concrete modifier, the CSS value vocabulary and
DOMEventHandlers go back to internal: 108 new public declarations become 29, and
the new public types are DOMEffectView, DOMElementModifier, DOMElementModifiers
and AnyUnmountable.

BindingModifier and FocusModifier had been given public witnesses they never
needed - an internal conformer does not require them.

some rather than any: any DOMElementModifier cannot satisfy the constraint at all,
and Embedded Swift allows only class-bound existentials. Opaque types monomorphise,
so embedded output is unchanged across the five size targets and memory is
identical on the performance suite.

Adds a test that only compiles if the modifier really is declared once.

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

Copy link
Copy Markdown
Contributor Author

ModifiedContent<some Modifier, Self> is exactly it, and DOMEffectView already was that type. Only the return types changed:

-> DOMEffectView<some DOMElementModifier, Self>

Named wrapper, so the conditional conformances still pick the namespace. Opaque modifier, so no concrete modifier appears in a public signature — and an internal type needs no public witnesses, which is what keeps CSSOpacity/CSSTransform/CSSFilter, the payload structs and DOMEventHandlers internal.

What do you think now?

None of these four are meant to be used: with the modifier opaque, _DOMEffectView
cannot even be named outside the module, since its first generic argument is
deliberately unspellable. The underscore matches _Mountable, _AttributeModifier
and the rest of the reconciler surface, and keeps them out of generated docs.

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

Copy link
Copy Markdown
Contributor Author

Also underscored what stays public: _DOMEffectView, _DOMElementModifier, _DOMElementModifiers, _AnyUnmountable.
_DOMEffectView has no public initialiser and the modifier in its return type is opaque, so outside the module you can only receive one, never name or build one. Structural, like _Mountable.

@sliemeobn

Copy link
Copy Markdown
Collaborator

I think this is approaching the right shape now, thanks.

can you please rebase onto latest main so we can run CI on it?

Vithanco and others added 2 commits August 24, 2026 15:35
Local pnpm runs rewrote libc fields in three lockfiles; unrelated to this change.

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

Copy link
Copy Markdown
Contributor Author

done

@sliemeobn

sliemeobn commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

@Vithanco thank you for your patience, and my apologies for being annoying - but after a bit of back an forth I think my preference is the original shape: explicit copies of the API, with "clean and simple" some View / some SVGView return types - both returning the same internal type under the hood.

it feels the bit of duplication is something very easily dealt with nowadays - in contrast to the somewhat messy exposure of underscored types + more complicated type gymnastics for these extensions. so I think the price of a tiny bit more maintenance burden is justified by the added simplicity for package consumers / documentation readers.

wdyt? do you have a preference?

also, I vaguely remember that svg elements have a different geometry behavior with transformation (eg: not "center" as anchor by default) - I have not tested it, but maybe we run into trouble by omitting the anchor for ".center" in the transforms for SVG?

@Vithanco

Vithanco commented Aug 27, 2026 via email

Copy link
Copy Markdown
Contributor Author

@sliemeobn

Copy link
Copy Markdown
Collaborator

I think taking on a little more code duplication for a simpler API and cleaner docs is worth it, so if I could ask you to finalize the PR with this shape (and maybe double-check that SVG rotations/scaling actually works as expected) we can finally get this thing merged 🙏

Vithanco and others added 4 commits August 28, 2026 21:43
Reverts the shared MarkupContent extensions and everything they required: the
opaque return types, the public and underscored DOMEffectView surface, and the
@_spi on the mount requirement. DOMEffectView is internal again, and View and
SVGView each declare their own modifiers returning some View<Tag> and
some SVGView<Tag>.

A little duplication in exchange for no exposed internals and simpler signatures
for package consumers.

The cross-namespace test goes with it: with two declarations there is no longer a
single one to pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rotationEffect and scaleEffect do not work on SVG: transform-origin resolves
against the viewBox rather than the element, so rotate(45deg) on a rect at
(20,20)-(60,60) lands its centre at (0,56.6) instead of (40,40), and a
topLeading anchor puts it off-canvas at (-100,98) - the percentage translate
the anchor uses assumes an origin already at the element's centre.

Both need transform-box: fill-box plus an explicit transform-origin, which is
worth doing on its own rather than smuggling into this PR. offset stays: it
translates by absolute pixels and is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old note said one extension cannot serve both namespaces. It can - the PR
history proves it - just not while returning some View<Tag> / some SVGView<Tag>,
which is what keeps the wrapper internal. Saying "cannot" invites the next reader
to rediscover that the hard way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notes about rotationEffect, scaleEffect and the Float offset overload
explained absences that only exist relative to this branch's history - on main
those functions were never in the file. The note above DOMEffectView restated
what the four conformances below it say precisely, and tightening the constraint
back would fail to compile anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The note asked whether this was still an issue in 6.3. It is: making _onEvent
internal fails the embedded build of Counter in _FunctionNode.swift with "cannot
use metatype of type 'some View<HTMLTag.button>'" and "a protocol type cannot
contain a generic method 'unmount'". Public builds clean on the same toolchain,
so the trigger is the opaque return type reaching the function-node machinery,
not the declaration.

Recheck target moved to 7.0. The SVG copy keeps pointing at this note.

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

Vithanco commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Done — mirrored shape, DOMEffectView internal again with the conditional conformances
doing the work, each namespace returning some View<Tag> / some SVGView<Tag>.

You were right about the transforms: transform-origin resolves against the viewBox on
SVG, not the element, so I've removed rotationEffect/scaleEffect and they'll resurface
as a separate PR. offset stays — absolute pixels, unaffected.

Also "answered" your TODO: embedded on _onEvent while I was in there: still needed on
6.3.3. Internal fails the embedded build in _FunctionNode.swift with cannot use metatype of type 'some View<HTMLTag.button>' and a protocol type cannot contain a generic method 'unmount' — so it's the opaque return type reaching the function-node machinery rather
than the declaration. I've moved the recheck to 7.0.

The revert to the mirrored shape checked this file out of the pre-merge commit
along with the ones the design change actually touched, which undid the attribute
storage rework from "maybe fixed embedded crasher" (elementary-swift#121). This PR never had a
reason to change _ElementNode at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Vithanco
Vithanco marked this pull request as draft August 29, 2026 05:24
Vithanco and others added 2 commits August 29, 2026 07:35
The suites only ever mounted, so _patchNode had no SVG coverage at all - half of
DOMEffectView, and the half a reconciler change is most likely to break. Adds
patching for opacity, offset and filters, patching an attribute through a
wrapping modifier, growing a recursive SVGView, and unmounting a modified
element.

Three assertions matched any value for transform and filter, so a wrong CSS
string would have passed; they now pin the exact string. saturation and
brightness were untested, as was modifier layering - stacking two filters or two
transforms composes through upstream/layerNumber and had no test in either
namespace. Also covers styling an SVG container rather than a leaf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every SVG style modifier's doc comment ends with "Changes to X are automatically
animated when done in an animated transaction", which nothing checked. It holds:
opacity and transform changes inside withAnimation start an animation, and a
plain change outside one emits a setStyle op instead.

The suite has to be serialized for that, the way TransitionMountRootTests already
is - withAnimation leaks into tests running in parallel, which made three
unrelated patch tests see their ops swallowed by an animation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Vithanco
Vithanco marked this pull request as ready for review August 29, 2026 05:47
Transaction._current is a process-global, not task-local, which is fine for the
single-threaded browser runtime but not for swift-testing's parallel suites: a
withAnimation in one test leaks into whatever runs beside it, and tests asserting
setStyle ops see them swallowed by an animation instead.

@suite(.serialized) is not enough - it only orders tests within a suite, and the
leak crosses suites. Measured: 2 failures in 5 runs with these tests, 0 in 6
without, and the failures move around.

The documented "automatically animated in an animated transaction" behaviour is
still worth covering; it needs the leak fixed first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
swift format lint is part of CI and this failed it: two runs of stray blank
lines with trailing whitespace, where rotationEffect and scaleEffect used to be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sliemeobn
sliemeobn merged commit 7d8473b into elementary-swift:main Sep 1, 2026
13 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants