Modifier
One DSL element, UiModifier, decorates the single element it wraps. What reaches the wire depends on
which members you set, because the two halves degrade differently on an older reader:
- The
modifiersproperty carries the members that change no geometry. It sits on the wrapped node itself, and a reader that does not know it ignores it and draws the node plainer. - The
ui.modifiertype carries padding, opacity, clip, mask and frame. Ignoring any of them would draw something wrong or move its siblings, so they are a negotiated type: an older reader draws your explicitFallback, and there is no default one. Without a fallback, an older reader draws none of the wrapped content (Macro Deck’s own renderer shows a faint placeholder box in its place) - see The UI model.
ui.modifier (component version 1)
Example
Section titled “Example”new UiModifier{ Key = "card", Background = UiGradient.Linear(135, new UiGradientStop { Offset = 0, Color = "#2b6cee" }, new UiGradientStop { Offset = 1, Color = "#7a3cf0" }), Radius = 0.12, Padding = 0.08, Child = new UiImage { Key = "icon", Source = icon }, Fallback = new UiImage { Key = "iconPlain", Source = icon },}
Padding makes this a ui.modifier node, so the gradient and radius sit on the wrapper and cover the
padding. An older reader draws the plain icon.
Decorating a node
Section titled “Decorating a node”new UiModifier{ Key = "roomCard", Background = "#1c1c1e", Radius = 0.1, BorderWidth = UiSize.Capped(2d / UiLength.Cell, 2), BorderColor = "#ff9500", BorderLine = UiComponentBorderLines.Dashed, AccessibilityLabel = UiText.Of(strings.RoomLabel), Child = new UiStack { Key = "room", Children = [/* ... */] },}{ "id": "room", "type": "ui.stack", "properties": { "modifiers": { "background": "#1c1c1e", "radius": { "basis": 0.1 }, "borderWidth": { "basis": 0.0167, "maxOfCell": 0.0167 }, "borderColor": "#ff9500", "borderLine": "dashed", "accessibilityLabel": {"$localized":…} } }, "children": [] }

With only these members set, the modifier adds no node: the members land on the child’s modifiers
object and the id stays the child’s. The child must then build to exactly one component node - a UiWhen,
UiRepeat or fragment child is rejected when the view is built. Nested modifiers of this kind merge onto
the same node; setting the same member twice on one node is rejected. Because it produces no node of its
own, such a modifier cannot set MainSize, Fill, ColumnSpan, RowSpan, Answer, Fallback or
RequiredComponentVersion - set those on the child, or add a wrapper member to make it a ui.modifier
node.
Events on a modifier
Section titled “Events on a modifier”new UiModifier{ Key = "pagerGestures", Events = [UiEventHandler.On(UiComponentEvents.Swipe, e => { if (e.TryGetString(out var dir)) Page(dir); })], Child = pager,}A modifier’s handlers join the declared events of the node its members land on. When the modifier and the child both handle a name, both run: the child first, then the modifiers from the inside out.
Gestures
Section titled “Gestures”| Event | Fires | Payload |
|---|---|---|
drag (UiComponentEvents.Drag) |
While the pointer moves, once it has travelled 0.04 of the basis. At most every 100 ms. |
{"x":n,"y":n}, the translation since the gesture began, in basis fractions, x right-positive, y down-positive |
drag-end (UiComponentEvents.DragEnd) |
Once on release, only after a drag began. Not sent if the node leaves the tree or becomes disabled mid-drag. |
As drag, the final translation |
swipe (UiComponentEvents.Swipe) |
On release, when the dominant axis travelled at least 0.2 of the basis within 500 ms. |
"left", "right", "up" or "down" |
pinch (UiComponentEvents.Pinch) |
While two pointers move. At most every 100 ms. |
A bare number, the scale since the pinch began |
pinch-end (UiComponentEvents.PinchEnd) |
Once, when the pinch ends. Not sent if the node leaves the tree or becomes disabled mid-pinch. | As pinch, the final scale |
The constants live on UiComponentModifiers (GestureSlop, SwipeMinDistance, SwipeMaxDurationMs,
GestureThrottleMs). A gesture can be declared on any node, with or without a modifier.
- Inner controls win. A pointer that starts inside a descendant that takes a value (a slider, dial,
toggle, segmented control or text field), declares a gesture of its own, or is a
ui.listbelongs to that descendant. - An inner press wins until slop. Once the pointer travels past
0.04of the basis, the outer gesture takes over and the press ends withpress-endand nopress. - Touch action. A node declaring a gesture turns off the browser’s own panning and zooming for its box.
A
ui.listinside it keeps scrolling. - On a deck tile, a gesture claims the pointer but not a key. A tree declaring a gesture takes every pointer press on its tile, so the tile’s own long-press cannot fire in the middle of a drag. Keyboard and physical-control activation cannot drag, so a tree that declares only gestures still runs the tile’s own flow from a key.
pinchon iOS, and every gesture at the Safari 9 floor, is unverified.
Pointer streams and taps
Section titled “Pointer streams and taps”drag, swipe and pinch report a recognised gesture. For a surface that needs every finger - a touchpad,
a drawing pad, a joystick - declare the pointer family instead:
new UiModifier{ Key = "touchpad", Events = [ UiEventHandler.On(UiComponentEvents.PointerDown, e => { if (e.TryGetPointerDown(out var down)) fingers[down.Sample.Id] = down.Sample; }), UiEventHandler.On(UiComponentEvents.PointerMove, e => { if (!e.TryGetPointerSamples(out var samples)) return; foreach (var sample in samples) { if (fingers.Count == 1 && fingers.TryGetValue(sample.Id, out var last)) mouse.MoveBy(sample.X - last.X, sample.Y - last.Y); fingers[sample.Id] = sample; } }), UiEventHandler.On(UiComponentEvents.PointerUp, e => { if (e.TryGetPointerUp(out var up)) fingers.Remove(up.Sample.Id); }), UiEventHandler.On(UiComponentEvents.Tap, e => { if (e.TryGetTap(out var pointers)) mouse.Click(pointers == 2 ? MouseButton.Right : MouseButton.Left); }), ], Child = new UiStack(),}fingers is a Dictionary<int, UiPointerSample> the view keeps, and mouse stands for the plugin’s own
input code.
| Event | Fires | Payload |
|---|---|---|
pointer-down (UiComponentEvents.PointerDown) |
A finger, pen or primary mouse button went down on the node. | {"id":n,"x":n,"y":n,"t":n,"width":n,"height":n} |
pointer-move (UiComponentEvents.PointerMove) |
Pointers moved. At most every 16 ms, carrying every position since the previous one, except that waiting samples are sent at once before any other event of the family. |
{"samples":[{"id":n,"x":n,"y":n,"t":n}, ...]}, oldest first, at most 256 |
pointer-up (UiComponentEvents.PointerUp) |
A pointer lifted, or the platform cancelled it. | {"id":n,"x":n,"y":n,"t":n}, plus "cancelled":true for a cancelled one |
tap (UiComponentEvents.Tap) |
After the last pointer-up of a touch that took at most 400 ms from the first finger down to the last finger up, in which no pointer travelled more than 0.04 of the basis and none was cancelled. |
{"pointers":n}, the most fingers down at once |
- Ids.
idis an opaque integer that stays the same from a pointer’spointer-downto itspointer-up. Every reader starts its ids at a random point, so two decks on one shared widget reporting the same id is very unlikely, though not impossible. - Positions are relative to the node’s top-left corner in basis fractions,
xright-positive,ydown-positive;widthandheightonpointer-downgive the node’s size in the same unit. Inside a rotatedui.transformthey are in screen axes, asdragis. - Time.
tis whole milliseconds since the first finger of the current touch went down, so samples of different fingers line up. - Order. Waiting samples are always sent before any other event of the family, and
tapafter the lastpointer-up. - A newer move may replace an older one. While a
pointer-movefor the same node from the same client is still waiting to be delivered, Macro Deck and the plugin runtime may drop it in favour of the newer one, and a move the plugin could not take because it was busy or slow is dropped instead of ending the session. A finger that appeared only in a dropped batch keeps its older position until it moves again or lifts. Read time fromt, never from the number of events. - Handle
pointer-movesynchronously and do not rebuild the tree for each one. An asynchronous handler is started for every event and not awaited, so slow ones pile up and may finish out of order. UI updates are limited to 30 a second (a burst of 90); a tree that changes on every move soon ends its session. pointer-upis not guaranteed after a connection ends. One follows eachpointer-downwhile the node still declares it, is enabled and is in the tree. Nothing arrives after the client disconnects or leaves the view, or when a stalled connection drops events: release whatever a finger holds when the session ends, and do not keep a mouse button held for a finger that has sent nothing for a while.- The node owns its fingers. A pointer that starts on a node declaring any of the four belongs to it: an
ancestor’s
drag,pinchor press does not take it, the browser does not pan under it, and aui.listcannot be scrolled from a touch that starts there. Nested nodes: the innermost one owns the finger, so one touch taps once. - Buttons on a pad. A finger that starts on a button inside the node is still streamed to the node, the
button still presses, and that touch sends no
tap. A touch on a node that declares a press name itself sends notapeither. Once a streamed finger travels past the slop, a press it started ends withoutpress, as withdrag. - Keys and hardware. A tree declaring only the pointer family still runs the tile’s own flow from a key or a hardware deck; nothing is streamed from them. The interactive screensaver does not pass the pointer to these nodes.
- The constants are
UiComponentModifiers.PointerMoveIntervalMs,PointerMoveMaxSamplesandTapMaxDurationMs. Below the Safari 9 floor the pointer family is unverified.
Disabled
Section titled “Disabled”new UiModifier { Key = "controlsState", Disabled = UiValue.From(() => !connected.Value), Child = controls }Disabled is shorthand for the producer, not a second source of truth: events stay the
contract.
- The DSL strips events. While
Disabledis true, no node in the wrapped subtree, and none in its fallback, declares an event, andUiView.DispatchanswersIgnoredfor any of them. - On the wire it is presentation.
disabled: truedims the node to0.4opacity once - a disabled region inside another is not dimmed twice - and marks the subtreearia-disabled. - Readers refuse events inside a disabled region, even when a hand-written tree still declares them, and a slider, dial, toggle, segmented control or text field inside one cannot be operated.
- A disabled region absorbs every press on the tile. If any node in a deck tile’s tree is disabled,
pressing anywhere on the tile runs none of the tile’s own flows.
Disabledtherefore means “a control that is unavailable”; to fade something that is only decorative, useOpacityinstead. On a hardware deck the host asks a plugin tile’s tree once per press. A tree that does not answer within a second, or a plugin already at its limit of open views, absorbs that press as well.

An older reader ignores disabled and draws the region undimmed. Because the DSL stripped its events, it
offers none of them either, but it does not absorb the tile’s press: on a deck tile, the tile’s own flows
still run there, as they do from a hardware key today.
Known gaps:
- The web client’s keyboard and hardware activation honours the absorption but not the tree’s own activation.
Properties
Section titled “Properties”modifiers (on any node)
Section titled “modifiers (on any node)”| DSL member (wire member) | Values | Absent | An older reader |
|---|---|---|---|
Background (background) |
#rrggbb, a linear or a radial gradient |
No change | Draws the node’s own background |
Radius (radius) |
length | The node’s own corner | Draws the node’s own corner |
BorderWidth (borderWidth) |
length | No border | Draws no border |
BorderColor (borderColor) |
#rrggbb |
The reader’s choice - set it whenever the colour matters | Draws no border |
BorderLine (borderLine) |
solid, dashed, dotted |
solid |
Draws no border |
AccessibilityLabel (accessibilityLabel) |
localized text | No label | Omits the label |
AccessibilityHint (accessibilityHint) |
localized text | No hint | Omits the hint |
Disabled (disabled) |
true |
Not disabled | Draws the node undimmed |
A gradient travels as {"linear":{"angle":deg,"stops":[{"offset":0..1,"color":"#rrggbb"}]}}, angle in the
CSS convention (0 towards the top, clockwise), or {"radial":{"centerX":0..1,"centerY":0..1,"stops":[...]}},
with at least two stops; a reader draws no gradient from fewer, or from any stop it cannot read.
Colours are literal, like every data colour - see Theming. On a
stack, button or list, background and radius replace the component’s own background and corner.
ui.modifier
Section titled “ui.modifier”| DSL member (wire property) | Values | Absent |
|---|---|---|
Padding (padding) |
length | No padding |
Opacity (opacity) |
0..1 |
Fully opaque |
Clip (clip) |
bounds, circle, capsule |
Nothing is clipped |
Mask (mask) |
a linear or radial gradient of {"offset","opacity"} stops |
No mask |
Frame (frame) |
width, height, minWidth, maxWidth, minHeight, maxHeight lengths, aspectRatio greater than 0 |
The box the parent hands it |
MainSize (mainSize), Fill (fill), Answer (answer) |
- | Shared with every container - see Stack and layer. |
The wrapper node may also carry modifiers and events. Enum values live in UiComponentClips and
UiComponentBorderLines.

Why padding is here. Padding could have been a plain property, but padding on an arbitrary node changes that component’s own geometry, so a reader ignoring it would lay the siblings out wrongly rather than just more plainly.
Children
Section titled “Children”Exactly one element.
Layout
Section titled “Layout”The wrapper lays its child out in its own box minus padding on every edge. frame fixes or clamps the
wrapper’s box and, with aspectRatio, its shape; a frame smaller than the space it is given is centred in
it. On its parent’s main axis the wrapper is its child’s content extent plus twice the padding, or the
frame’s fixed length, clamped by the frame’s minimum and maximum.
Wrapping moves MainSize and Fill to the wrapper: set them on the UiModifier, since a wrapped child
that sets them is rejected when the view is built. A filling wrapper’s maximum clamps only its own size;
the surplus is not handed to its siblings. See Sizing.
Reader behaviour
Section titled “Reader behaviour”radiusdoes not clip. It rounds the background and border only; to clip content, useclipon a wrapper.clip: boundsrounds by the wrapper’s ownmodifiers.radius.radiusis ignored on the tree root, because the tile’s corner belongs to the surface (ADR 0065).- The border is drawn inside the edge. It takes no space, so the node’s content and its
children’s boxes are unchanged. On a stack, button, layer, transform, grid, toggle, segmented control or
ui.modifierit is a layer above the content, including a button’s artwork; on every other node it is an outline, which an older engine may draw with square corners. - Opacity covers the whole wrapper; a disabled wrapper at
opacity: 0.5shows at0.2. - A fallback is its own node. A reader drawing a node’s fallback draws the fallback’s own
modifiers, not the replaced node’s; give the fallback the ones it needs. - Accessibility.
accessibilityLabelbecomes the node’s accessible name andaccessibilityHintits description, the latter with weaker support at the Safari 9 floor. A labelled node that claims a press gets the button role, other labelled nodes without a native role the group role; a text field keeps its own. - A reader that does not know
ui.modifierdraws the node’sfallback, negotiated in turn. Without one it draws none of the wrapped content (Macro Deck’s own renderer shows a faint placeholder box in its place), per The UI model. Give a wrapper a fallback built from the same content under its own keys:
{ "id": "card", "type": "ui.modifier", "properties": { "padding": { "basis": 0.08 }, "modifiers": { "radius": { "basis": 0.12 } } }, "children": [{ "id": "icon", "type": "ui.image", "properties": { "source": { "resourceId": "icon" } }, "children": [] }], "fallback": { "id": "iconPlain", "type": "ui.image", "properties": { "source": { "resourceId": "icon" } }, "children": [] }}The shared fixture
conformance-modifier-tree.json
covers every member, property and event on this page.