react-x11 2.51.0 → 2.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +87 -54
  2. package/docs/README.md +410 -0
  3. package/docs/accessibility.md +628 -0
  4. package/docs/appearance.md +416 -0
  5. package/docs/architecture/accessibility.md +470 -0
  6. package/docs/architecture/animation.md +707 -0
  7. package/docs/architecture/container-queries.md +525 -0
  8. package/docs/architecture/custom-layout.md +334 -0
  9. package/docs/architecture/drag-and-drop.md +941 -0
  10. package/docs/architecture/element-layer-contents.md +622 -0
  11. package/docs/architecture/frame-pacing.md +255 -0
  12. package/docs/architecture/grid-layout.md +358 -0
  13. package/docs/architecture/protocol-efficiency.md +357 -0
  14. package/docs/architecture/video.md +1149 -0
  15. package/docs/architecture/windowed-regions.md +854 -0
  16. package/docs/click-to-component.md +125 -0
  17. package/docs/clipboard.md +281 -0
  18. package/docs/components.md +1749 -0
  19. package/docs/dbus.md +277 -0
  20. package/docs/debugging.md +464 -0
  21. package/docs/desktop-calendar.md +284 -0
  22. package/docs/desktop.md +648 -0
  23. package/docs/devtools.md +250 -0
  24. package/docs/drag-and-drop.md +663 -0
  25. package/docs/ecosystem/animation.md +394 -0
  26. package/docs/ecosystem/data-fetching.md +201 -0
  27. package/docs/ecosystem/dev-tooling.md +436 -0
  28. package/docs/ecosystem/forms.md +313 -0
  29. package/docs/ecosystem/headless.md +219 -0
  30. package/docs/ecosystem/i18n.md +89 -0
  31. package/docs/ecosystem/icons.md +410 -0
  32. package/docs/ecosystem/layout.md +216 -0
  33. package/docs/ecosystem/routing.md +146 -0
  34. package/docs/ecosystem/state.md +361 -0
  35. package/docs/ecosystem/testing.md +500 -0
  36. package/docs/ecosystem/theming.md +540 -0
  37. package/docs/ecosystem.md +473 -0
  38. package/docs/elements.md +2762 -0
  39. package/docs/embedding.md +288 -0
  40. package/docs/events.md +1025 -0
  41. package/docs/extending.md +2242 -0
  42. package/docs/eyedropper.md +223 -0
  43. package/docs/filedialog.md +258 -0
  44. package/docs/frame.md +580 -0
  45. package/docs/gl.md +170 -0
  46. package/docs/globalmenu.md +278 -0
  47. package/docs/glx.md +137 -0
  48. package/docs/macos.md +3295 -0
  49. package/docs/notifications.md +164 -0
  50. package/docs/packaging.md +611 -0
  51. package/docs/permissions.md +148 -0
  52. package/docs/react-features.md +430 -0
  53. package/docs/remote.md +223 -0
  54. package/docs/scale.md +339 -0
  55. package/docs/security.md +132 -0
  56. package/docs/styling.md +1928 -0
  57. package/docs/system.md +599 -0
  58. package/docs/testing.md +514 -0
  59. package/docs/typescript.md +117 -0
  60. package/docs/uri-schemes.md +380 -0
  61. package/docs/wayland-backend.md +591 -0
  62. package/docs/wayland.md +545 -0
  63. package/docs/windows-embedding.md +271 -0
  64. package/docs/windows-gl.md +221 -0
  65. package/docs/windows-integrations.md +233 -0
  66. package/docs/windows.md +1404 -0
  67. package/package.json +10 -3
  68. package/src/bus.js +39 -26
  69. package/src/cli/create.d.ts +43 -0
  70. package/src/cli/create.js +305 -0
  71. package/src/cli/env.js +484 -0
  72. package/src/cli/index.js +29 -2
  73. package/src/cli/templates/AGENTS.template.md +195 -0
  74. package/src/cli/templates/DESIGN.template.md +269 -0
  75. package/src/desktopintegration.js +2 -1
  76. package/src/desktopsettings.js +193 -24
  77. package/src/filedialog.js +3 -3
  78. package/src/keyboardstate.js +28 -0
  79. package/src/node.d.ts +10 -10
  80. package/src/portal.js +31 -0
  81. package/src/screencolor.js +5 -10
  82. package/src/types/system.d.ts +8 -6
  83. package/src/wayland/app.js +59 -0
  84. package/src/wayland/clipboard.js +14 -7
  85. package/src/wayland/connection.js +3 -0
  86. package/src/wayland/context2d.js +129 -10
  87. package/src/wayland/protocols/index.json +6 -0
  88. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  89. package/src/wayland/seat.js +28 -1
  90. package/src/wayland/swapchain.js +23 -0
  91. package/src/wayland/xkb.js +137 -0
@@ -0,0 +1,707 @@
1
+ # Animation in styles: timelines, opacity, transforms, and who interpolates
2
+
3
+ _Design document, 2026-09-06. Written against master at c9ce902 (react-x11
4
+ 2.6.1, ntk ^8.7.0, `@windowkit/appkit` ^0.4.0). File:line references will
5
+ drift with the code; the shape of the argument should not._
6
+
7
+ ---
8
+
9
+ ## 0. TL;DR
10
+
11
+ The style vocabulary already has two animation shapes — `transition` (a
12
+ change, over so many ms) and `animation` (a loop between two values) — and
13
+ one runtime that drives both: the window's frame clock, JS interpolation,
14
+ a damage claim per frame. That runtime is correct on every backend and is
15
+ the fallback everything below degrades to.
16
+
17
+ What an author cannot write today, in order of how often it comes up:
18
+
19
+ 1. **A fade.** There is no `opacity`. The ecosystem page opens by saying so.
20
+ 2. **A move that does not reflow.** There is no `translate`/`rotate`/`scale`;
21
+ the Switch thumb slides on `start` and pays a yoga pass per frame for it.
22
+ 3. **A timeline that ends.** `animation` loops forever by definition; an
23
+ entrance, a shake, a pulse-three-times have no spelling.
24
+ 4. **More than two stops**, an easing on a transition, a delay, a spring.
25
+ 5. **Motion that survives a busy JS thread.** On macOS the retained layer
26
+ presenter can hand a transition to Core Animation and stop scheduling
27
+ frames for it; today it sets layer properties per frame like everything
28
+ else.
29
+
30
+ The proposal is one vocabulary, four additions, and one execution rule:
31
+
32
+ - **Easing** becomes cubic-bezier control points under the hood, with names
33
+ as aliases and springs as a second kind. This is the load-bearing change:
34
+ it is what lets a JS evaluator and Core Animation agree on the same
35
+ declaration (§3.1 has the numbers — mapping today's ease-out to CA's
36
+ _named_ ease-out is 22% off at the peak; to control points it is 0.3%).
37
+ - **`transition`** grows a per-property object form with `easing` and
38
+ `delay`, and `all` for the shorthand's slot.
39
+ - **`animation`** grows `keyframes`, `repeat` (default `Infinity`, so every
40
+ existing loop keeps its meaning), `delay`, and `key` to re-trigger. A
41
+ finite one rests at the declared value when it ends — the rule loops
42
+ already follow, which turns out to be CSS's `fill-mode: none` for free.
43
+ - **`opacity`, `translateX`, `translateY`, `rotate`, `scaleX`, `scaleY`** as
44
+ paint-only style properties on both backends. Free on a layer; an
45
+ offscreen composite on X11, bounded to the subtree, with fast paths for
46
+ the common cases (a leaf's opacity, a pure translate).
47
+ - **The presenter decides who interpolates.** The node model stays the one
48
+ source of truth for _what_ is animating and _when it ends_. A presenter
49
+ may take a property it can express on the node's own layer; anything it
50
+ declines runs on the JS loop exactly as now. The layer presenter takes
51
+ paint properties, opacity and transforms; layout properties stay on the
52
+ JS loop everywhere, because yoga has to run.
53
+
54
+ Five bridge verbs stand between the layer presenter and that offload
55
+ (§4.4). The first — control-point timing functions — is a prerequisite,
56
+ not an enhancement: without it every existing transition would change its
57
+ look the day it moved to the render server.
58
+
59
+ **Status, 2026-09-06.** Bridge items 1–8 shipped in `@windowkit/appkit`
60
+ 0.5.0 (§4.4). Landed in react-x11 on top of it: the presenter seam of §4.1
61
+ and the PropBox rows of §4.2 (`animate`/`cancel` in
62
+ src/cocoa/presenter.js, the `offloaded` entries in src/nodes/animation.js), with additive
63
+ retargets for lengths and presentation-value retargets for colours, and
64
+ reduce motion on the Cocoa backend (src/desktopsettings.js's `fromMacOS`).
65
+ The vocabulary of §3 — easing, timelines, `opacity`, transforms — is still
66
+ to come; the control-point tables it needs exist (`EASING_CONTROL_POINTS`).
67
+
68
+ _Since then, `opacity` has landed (#601) — on Cocoa an opacity transition
69
+ or loop runs in the render server (#818) — and so has a loop's `delay`
70
+ (#605). Transform style properties, keyframes and finite timelines have
71
+ not._
72
+
73
+ ## 1. What exists
74
+
75
+ ### 1.1 The vocabulary
76
+
77
+ `transition: number | { [prop]: ms }` — a number covers every animatable
78
+ property, an object picks them (docs/styling.md "Transitions"). One fixed
79
+ ease-out cubic (`ease`, src/styles.js:1121). A transition starts from what
80
+ is on screen, so an interrupted one reverses from where it got to
81
+ (`_retarget`, src/nodes/animation.js), and it starts at `now()` rather than the
82
+ last frame's timestamp, so an idle window does not find it already over
83
+ (#80). Nothing before a node's first frame transitions — an inserted
84
+ element appears at its style (`_placed`, test/transition-mount.test.js).
85
+ Layout properties may transition and cost a layout pass per frame; enums,
86
+ `zIndex`, `boxShadow` and `backgroundImage` snap (`NOT_ANIMATABLE`,
87
+ src/styles.js:726).
88
+
89
+ `animation: { [prop]: { from?, to, duration, easing?, alternate? } }` — a
90
+ loop (#352). `from` defaults to the declared value, which is also where the
91
+ property rests whenever the loop is not running. Four named easings,
92
+ `linear` by default because a cycle that eases out stutters at the wrap.
93
+ Phase is a modulo of elapsed time (`animationValueAt`, src/styles.js:965).
94
+ An equal declaration keeps its phase across re-renders (`sameAnimation`).
95
+ It stops when the window is unmapped, minimized or obscured, when anything
96
+ above the node hides it, when the node unmounts or the style drops it, and
97
+ under reduced motion (`_loopsAllowed`, src/nodes/animation.js) — and every one
98
+ of those restarts it from the top when it goes away.
99
+
100
+ ### 1.2 The runtime
101
+
102
+ One path for both shapes. `_retarget` puts an entry per property into
103
+ `node._anim` and the node into `root._animating`; `flush` calls
104
+ `_advanceAnimations(now())` (src/nodes/animation.js), which claims each
105
+ animating node where it stands, ticks it, and sets `needsPaint`. That claim
106
+ is where the node _was_; a tick on a layout property asks for a layout pass,
107
+ and the pass claims the old and new rect of every node it moves — the
108
+ animating one and whatever it pushed, in flow or not (`_assignAbs`,
109
+ src/nodes/layout.js) — so a layout animation repaints what it moves rather
110
+ than the window (#603). A box that moved and changed nothing else is one
111
+ claim at each end for its whole subtree, clipped to the boxes that clip it
112
+ (`_beginRigidMove`), and the nodes under it that yoga did not lay out again
113
+ are moved with it rather than read back out of yoga (`_followParent`). The
114
+ window keeps asking for frames while `_animating` is non-empty; the
115
+ animation _is_ the repaint loop. `interpolate` (src/styles.js:990) lerps
116
+ numbers, percentages of the same unit, and colours — premultiplied, so a
117
+ fade from `transparent` does not pass through grey.
118
+
119
+ `withFrameClock` in `react-x11/test` freezes the clock; the loop tests
120
+ (test/animation-loop.test.js) and the transition tests in test/style.test.js
121
+ drive it by hand.
122
+
123
+ ### 1.3 Where the pixels come from
124
+
125
+ Three presentation paths, and the animation runtime is identical on all of
126
+ them:
127
+
128
+ | backend | a frame is | can anything interpolate for us? |
129
+ | -------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------- |
130
+ | X11 (ntk / XRender) | damage rects → paint walk → XRender | no |
131
+ | Cocoa, `surface` presenter (**the default**) | the same paint walk into an IOSurface swapchain | no — one bitmap per window |
132
+ | Cocoa, `layers` presenter (opt-in) | dirty nodes → per-node visuals → one `CATransaction` of property diffs | **yes** — the render server animates layer properties |
133
+
134
+ The layer presenter (src/cocoa/presenter.js) keeps a visual per drawn node:
135
+ a **PropBox** for a plain `<box>` (frame, zPosition, hidden, masksToBounds,
136
+ cornerRadius, backgroundColor, borderWidth, borderColor — zero raster), a
137
+ **Raster** for everything else (the node's own paint replayed into a bitmap
138
+ that becomes the layer's `contents`), shape layers for `<svg>`, an overlay
139
+ for scrollbars, and a clip host whose `bounds.origin` is the scroll offset.
140
+ A JS-driven transition on this presenter works today by construction: each
141
+ tick invalidates the node, the frame re-diffs its properties and sends the
142
+ changed ones under `disableActions`. It is the same cost model as X11 with
143
+ a cheaper sink.
144
+
145
+ It is opt-in — `createRoot({ cocoa: { presenter: 'layers' } })` or
146
+ `REACT_X11_COCOA_PRESENTER=layers` — while docs/macos.md's measure-first
147
+ gate is open. Everything in §4 is about that presenter; on the surface
148
+ presenter there is nothing to hand an animation to.
149
+
150
+ ### 1.4 What the bridge can already do
151
+
152
+ `@windowkit/appkit`'s verbs, as react-x11 reaches them through `native`
153
+ (src/cocoa/native.js):
154
+
155
+ - `addAnimation(layer, keyPath, opts, key)` — a `CABasicAnimation`:
156
+ `from`/`to` (numbers, `[x, y]` points, `[r, g, b, a]` colours), `duration`
157
+ in **seconds**, `repeat` (a count, or `Infinity`), `autoreverse`, `timing`
158
+ as one of four **named** curves (`linear`, `easeIn`, `easeOut`,
159
+ `easeInEaseOut`; anything else is CA's default), and `hold` for
160
+ `fillMode: forwards` + `removedOnCompletion: NO`.
161
+ - `removeAnimation(layer, key)`, `removeAllAnimations(layer)`.
162
+ - `txBegin({ duration, timing, disableActions })` / `txCommit()` — the
163
+ implicit-animation route: set a property inside a transaction with a
164
+ duration and CA animates it.
165
+ - `setLayerProps` accepts `opacity`, `anchorPoint`, `position`, `bounds`,
166
+ the shadow quartet, and `transform` as
167
+ `{ translateX, translateY, rotate (radians), scale | scaleX, scaleY }`,
168
+ composed translate → rotate → scale — CSS's order for the individual
169
+ transform properties, and the shape §3.4 proposes.
170
+
171
+ What it cannot do, each of which §4.4 turns into an issue: a timing function
172
+ from control points; a keyframe animation; a spring; a delay (`beginTime`);
173
+ an additive animation; a completion callback; reading a presentation value;
174
+ and the desktop's reduce-motion switch, which on Cocoa never reaches
175
+ `desktopSettings().animations` at all (src/desktopsettings.js reads
176
+ XSETTINGS and nothing else, so a mac reports `animations: true` whatever
177
+ the user set).
178
+
179
+ ## 2. What cannot be said
180
+
181
+ Concrete, from the examples and the widget set:
182
+
183
+ - **Fade in a tooltip, a toast, a menu.** No `opacity`. The ecosystem page
184
+ tells people to animate a colour instead, and every entry on that page
185
+ exists partly because of this hole.
186
+ - **Hover lift.** `':hover': { scaleX: 1.02, scaleY: 1.02 }` — a paint-only
187
+ state change that today has no property to change. A state block cannot
188
+ touch layout, so growing the box is not an option either.
189
+ - **The Switch thumb.** `transition: { start: 120 }` is a layout pass per
190
+ frame on every backend and can never be offloaded, because `start` is
191
+ yoga's. As `translateX` it is a paint property: no layout on X11, a
192
+ `transform.translation.x` animation in the render server on layers.
193
+ - **A shake on a bad password.** Six stops in 300ms, once, and again on the
194
+ next attempt. Neither shape has a spelling for "once" or for "again".
195
+ - **An entrance.** Slide up and fade in on mount. Nothing may transition
196
+ before the first frame (correctly — the mount rule), and the loop shape
197
+ never ends.
198
+ - **A spinner.** `rotate` from 0 to 360, forever — the one loop that is
199
+ obviously a transform, and the one with no property to loop.
200
+ - **A staggered list.** Delay per row. No `delay`.
201
+ - **A transition with a curve.** `ease-in-out` on a panel slide; a spring on
202
+ a drag release so it carries the velocity. One fixed curve.
203
+ - **"It kept animating while the app was busy."** Any transition on macOS
204
+ stalls with the JS thread. docs/macos.md calls this the qualitative gap
205
+ Tier L exists for; it is not reachable until the presenter takes the
206
+ animation.
207
+
208
+ Out of scope here, so nobody expects them: **exit animations** (keeping a
209
+ node mounted after React removed it is a reconciler feature — a deferred
210
+ `removeChild` — and deserves its own design; `onAnimationEnd` below is the
211
+ seam it will need), and **window-level opacity** (a `<popup>` fade is
212
+ `_NET_WM_WINDOW_OPACITY` on X11 and `NSWindow.alphaValue` on Cocoa, a
213
+ different mechanism from a node's).
214
+
215
+ ## 3. The vocabulary
216
+
217
+ ### 3.1 Easing, shared
218
+
219
+ ```ts
220
+ type Easing =
221
+ | 'linear'
222
+ | 'ease'
223
+ | 'ease-in'
224
+ | 'ease-out'
225
+ | 'ease-in-out'
226
+ | [x1: number, y1: number, x2: number, y2: number] // cubic-bezier
227
+ | {
228
+ spring: {
229
+ stiffness?: number;
230
+ damping?: number;
231
+ mass?: number;
232
+ velocity?: number;
233
+ };
234
+ };
235
+ ```
236
+
237
+ **The canonical form is the four control points.** Names are aliases
238
+ resolved once, in `styles.js`, to the beziers they already are:
239
+
240
+ | name | control points | today |
241
+ | ------------- | ---------------------- | --------------------------------------- |
242
+ | `linear` | `[0, 0, 1, 1]` | `t` |
243
+ | `ease-out` | `[0.33, 1, 0.68, 1]` | `1 - (1 - t)^3`, the transition default |
244
+ | `ease-in` | `[0.32, 0, 0.67, 0]` | `t^3` |
245
+ | `ease-in-out` | `[0.65, 0, 0.35, 1]` | the piecewise cubic |
246
+ | `ease` | `[0.25, 0.1, 0.25, 1]` | — (CSS's default, new) |
247
+
248
+ Why this and not the polynomials: **the two evaluators have to agree.** A
249
+ declaration that runs on the JS loop on X11 and in the render server on
250
+ layers must look the same, and CA's only timing primitive is a cubic
251
+ bezier. Sampled at a thousand points (scratch script, not committed), the
252
+ existing curves against their bezier twins differ by at most 0.003
253
+ (ease-out, ease-in) and 0.0095 (ease-in-out) — invisible. The existing
254
+ ease-out against CA's **named** `easeOut` (`[0, 0, 0.58, 1]`) differs by
255
+ **0.216** at t = 0.35 — a visibly different animation. So the bridge's four
256
+ named curves are not a mapping target; control points are, and the JS side
257
+ gets a bezier solver (Newton with a bisection fallback, the shape every
258
+ browser ships, ~40 lines, pure, pinned by a table test against CSS's
259
+ published samples).
260
+
261
+ **Springs** are the second kind, not a curve: no duration, a settling time
262
+ derived from the physics, and the reason they exist is interruption — a
263
+ retarget carries the current velocity, which is what makes a dragged thing
264
+ let go feel like it was let go. CA's names (`mass`, `stiffness`,
265
+ `damping`, `initialVelocity`) are physical units and are also popmotion's
266
+ and Framer's; use them. A spring is only ever between two values, so it is
267
+ an easing for `transition` and for a two-stop `animation`; a keyframe
268
+ timeline takes beziers per segment. The JS evaluator is the closed-form
269
+ damped oscillator, settling at a displacement threshold; CA's is
270
+ `CASpringAnimation` and reports `settlingDuration`, which is what the
271
+ completion timer uses. Springs are the last thing to land (§7); the
272
+ vocabulary is fixed now so nothing built before them has to move.
273
+
274
+ ### 3.2 `transition`
275
+
276
+ ```js
277
+ transition: 120 // as today: everything, ease-out
278
+ transition: { backgroundColor: 120, borderColor: 120 } // as today
279
+ transition: {
280
+ all: 120, // the shorthand's slot
281
+ start: { duration: 160, easing: 'ease-in-out', delay: 40 },
282
+ scaleX: { easing: { spring: { stiffness: 400, damping: 30 } } },
283
+ }
284
+ ```
285
+
286
+ A property's value is `ms | { duration?, easing?, delay? }`; `all` is the
287
+ default for every animatable property not named, and is not a style
288
+ property, so the object stays one grammar. `duration` may be omitted only
289
+ with a spring. The default easing stays `ease-out` — a change that ends
290
+ looks right slowing into its value — and the default delay is 0.
291
+
292
+ `transitionFor` returns `{ duration, easing, delay }` instead of a number.
293
+ (The `.d.ts` already declares that shape — `{ duration; delay? } | null`,
294
+ src/style.d.ts:93 — and the JavaScript returns a number: one of #120's
295
+ drifts, closed by this change rather than by editing the declaration.)
296
+
297
+ `delay` is a start time, nothing more: the transition still reads its
298
+ `from` from what is on screen _when it starts_, so a retarget during the
299
+ delay is a retarget of a transition that has not moved.
300
+
301
+ ### 3.3 `animation`: a timeline, which may end
302
+
303
+ ```js
304
+ animation: {
305
+ // today's loop, unchanged
306
+ backgroundColor: { to: '$accent', duration: 900, alternate: true },
307
+ // an entrance: from somewhere, to the declared value, once
308
+ opacity: { from: 0, duration: 200, repeat: 1 },
309
+ translateY: { from: 8, duration: 200, repeat: 1, easing: 'ease-out' },
310
+ // a shake, and again on the next attempt
311
+ translateX: { keyframes: [0, -6, 6, -6, 6, 0], duration: 300, repeat: 1, key: attempt },
312
+ // a spinner
313
+ rotate: { from: 0, to: 360, duration: 800 },
314
+ // a staggered pulse
315
+ scaleX: { to: 1.1, duration: 600, alternate: true, delay: index * 80 },
316
+ }
317
+ ```
318
+
319
+ Per property, as now — CA needs one animation per key path too, and CSS's
320
+ one-`@keyframes`-many-properties shape would only be a way to write the
321
+ same duration several times. Options:
322
+
323
+ | key | |
324
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
325
+ | `from`, `to` | the two-stop form. **Exactly one may be omitted** and defaults to the declared value |
326
+ | `keyframes` | the many-stop form, instead of `from`/`to`: values evenly spaced, or `{ at: 0..1, value, easing? }` for placed stops and per-segment curves |
327
+ | `duration` | one pass, ms |
328
+ | `easing` | over the pass; `linear` default, as now |
329
+ | `delay` | before the first pass only |
330
+ | `repeat` | passes; **`Infinity` by default**, so a declaration written today means what it meant |
331
+ | `alternate` | as now |
332
+ | `key` | identity: a changed `key` restarts the timeline from the top, which is how a finite one is played again |
333
+
334
+ **Where it rests is the declared value — and that is the whole fill-mode
335
+ story.** Loops already have this rule: the property is at its declared
336
+ value before the first frame, off screen, under reduced motion. A finite
337
+ timeline ends and the property is at its declared value. So an entrance is
338
+ "declare the end state, arrive from somewhere else"; a shake ends where it
339
+ began because the author wrote `0` at both ends. There is no `fill:
340
+ 'forwards'`, deliberately: a held last frame means the style lies about the
341
+ property from then on, and the author who wants that value can write it.
342
+ A finite timeline whose last stop is not the declared value snaps at the
343
+ end, visibly — which is the declaration being honest, not a bug.
344
+
345
+ **Start and restart.** A timeline starts on the style swap that introduces
346
+ it — a mount, or a later change — the same moment loops start today. An
347
+ equal declaration keeps its phase (`sameAnimation`, extended to
348
+ `keyframes`, `repeat`, `delay`, `key`); a changed one restarts. A finished
349
+ finite timeline stays finished across re-renders of the same declaration;
350
+ `key` is the one field whose only job is to differ. (`key` reads well in a
351
+ React codebase; `run` was the alternative.)
352
+
353
+ **Completion.** `onTransitionEnd({ property })` and `onAnimationEnd({
354
+ property })` as element props, per property, fired when the JS clock says
355
+ so — on every presenter, including one that offloaded the pixels, so a
356
+ component can chain or unmount without knowing where the animation ran.
357
+ Loops never fire it. An interrupted transition does not fire it either
358
+ (CSS agrees: `transitioncancel` is a different event, and nothing here
359
+ needs it yet).
360
+
361
+ **Reduced motion.** The existing split holds and is the simple rule:
362
+ **`animation` is motion for its own sake and never starts under reduced
363
+ motion, finite or not; `transition` is the answer to an input and always
364
+ runs.** A shake and an entrance are the kind of thing the switch exists
365
+ for, and the still frame is the declared value — the same design decision
366
+ `<ProgressBar indeterminate>` already makes for its loop.
367
+
368
+ ### 3.4 `opacity` and the individual transforms
369
+
370
+ Six paint-only style properties, on both backends:
371
+
372
+ | property | value | notes |
373
+ | -------------------------- | ---------- | ---------------------------------------------------------- |
374
+ | `opacity` | 0..1 | **group** opacity of the subtree, CSS semantics |
375
+ | `translateX`, `translateY` | logical px | |
376
+ | `rotate` | degrees | the bridge takes radians; the presenter converts |
377
+ | `scaleX`, `scaleY` | factor | no `scale` — `<box scale>` is already the zoom (see below) |
378
+
379
+ Composed translate → rotate → scale about the box centre — CSS's order for
380
+ its individual transform properties and exactly what the bridge's
381
+ `transform` object does. A `transformOrigin` is the obvious later seam;
382
+ centre is CA's default anchor and the case every hover lift and spinner
383
+ wants.
384
+
385
+ Paint-only means: **state blocks may set them.** A `:hover` that scales a
386
+ card by 2% is a repaint of one node, no layout — the property that was
387
+ missing from the "hover is a repaint, not a render" story. It also means
388
+ layout is untouched: the box yoga computed is where the node _is_; the
389
+ transform is where it is _drawn_, and where the pointer finds it. Hit
390
+ testing maps the point through the inverse (the walk in `events.js` tests
391
+ node rects; a transformed node tests the inverse-mapped point against its
392
+ own rect and passes the mapped point to its children). `paintBounds()` is
393
+ the transformed box's bounding rect, which is what damage, culling and
394
+ `_subtreeBounds()` already consume. `opacity: 0` still hits, as in CSS;
395
+ `pointerEvents: 'none'` is the property for not hitting.
396
+
397
+ **Not the zoom.** `<box scale={2}>` multiplies every length in the subtree
398
+ and reshapes text at the size it will be drawn at (src/nodes/cascade.js,
399
+ docs/scale.md); it is layout, and it is expensive to change. `scaleX` is
400
+ paint: the rasterized subtree stretched. They are two different tools and
401
+ the transform is the one that animates cheaply; the docs say so in one
402
+ place so the names stop looking like a mistake.
403
+
404
+ Both animate (they are numbers), both may transition, both go in
405
+ timelines. That is the whole point of choosing individual properties over
406
+ a `transform: 'rotate(45deg) …'` string: the per-property machinery
407
+ interpolates them with no matrix decomposition, and each is one CA key
408
+ path (`opacity`, `transform.translation.x`, `transform.rotation.z`,
409
+ `transform.scale.x`).
410
+
411
+ ### 3.5 Deliberately not proposed
412
+
413
+ - **CSS strings** (`'200ms ease-in-out'`, `'rotate(45deg)'`). The style
414
+ language here is numbers and objects; a second grammar inside strings is
415
+ a parser and a source of errors that arrive as a thing that does not move.
416
+ - **`fill-mode`.** §3.3.
417
+ - **A per-frame callback in the style.** `animation` describes the motion
418
+ and the renderer runs it — the offload in §4 depends on that being true.
419
+ The imperative seam for a 2D per-frame draw is still open
420
+ (docs/styling.md, last paragraph of "Loops"); a `<canvas>` with a value
421
+ from popmotion is the answer meanwhile, and this proposal makes that
422
+ cheaper rather than replacing it.
423
+ - **Exit animations.** §2.
424
+
425
+ ## 4. Who interpolates
426
+
427
+ ### 4.1 The rule
428
+
429
+ **The node model is the source of truth for what is animating and when it
430
+ ends. A presenter may take the pixels.** Concretely, `_retarget` and
431
+ `_updateLoops` keep doing everything they do — the `_anim` entry, the
432
+ start time, `sameAnimation`, the loop-stop rules — and then ask:
433
+
434
+ ```js
435
+ presenter.animate?.(node, prop, entry); // → true: the presenter interpolates
436
+ ```
437
+
438
+ A presenter that answers `true` owns the frames for that entry: the node
439
+ is **not** added to `root._animating` for it, no damage is claimed per
440
+ frame, and the window's frame clock stays idle. The model value goes out
441
+ as it does now — the resolved target, in the disabled-actions transaction
442
+ — and the presenter has attached the motion to the layer. Completion is a
443
+ timer at `delay + duration` (or the spring's settling time), which fires
444
+ `onTransitionEnd`/`onAnimationEnd` and, for a finite timeline, lets the
445
+ property rest. A presenter that answers `false`, has no `animate`, or is the
446
+ X11 painter, gets the JS loop exactly as today. **Every entry has the JS
447
+ loop as its fallback**, so correctness never depends on coverage — the
448
+ same rule the layer presenter already applies to painting (raster per node
449
+ when the property vocabulary runs out).
450
+
451
+ Retarget and stop go through the same seam: `presenter.cancel(node, prop)`
452
+ before a new `animate`, and from every loop-stop path.
453
+
454
+ ### 4.2 What the layer presenter takes
455
+
456
+ A property qualifies when it maps to a property **of the node's own layer**
457
+ and changing it does not re-raster that layer:
458
+
459
+ | style property | key path | PropBox | Raster |
460
+ | --------------------------------------------- | -------------------------------- | ------- | -------------- |
461
+ | `backgroundColor`, `borderColor` | `backgroundColor`, `borderColor` | yes | no — re-raster |
462
+ | `borderWidth`, `borderRadius` | `borderWidth`, `cornerRadius` | yes | no |
463
+ | `opacity` | `opacity` | yes | **yes** |
464
+ | `translateX/Y`, `rotate`, `scaleX/Y` | `transform.*` | yes | **yes** |
465
+ | `boxShadow` components (once it interpolates) | `shadowOpacity/Radius/Offset` | later | — |
466
+ | `color` on `<text>` | — | — | no |
467
+ | any layout property | — | no | no |
468
+
469
+ The last row is the important one. **Layout properties stay on the JS loop
470
+ on every presenter**, because yoga has to run for the frame to be right —
471
+ a `width` that grows moves its siblings. The one tempting exception, an
472
+ absolutely positioned node's `left`/`top`/`start` as the layer's
473
+ `position`, is deferred: once `translateX` exists, the Switch thumb and
474
+ its kind have a paint property to slide on, which is cheaper on X11 too
475
+ and needs no exception. The `Raster` rows are what make transforms and
476
+ opacity worth more than they look: a paragraph, an image or a registered
477
+ element fades and moves in the render server with its bitmap untouched.
478
+
479
+ **Mechanics** (per property, inside the frame's transaction):
480
+
481
+ 1. Model value out as today: `visual.set({ backgroundColor: to })` under
482
+ `disableActions`.
483
+ 2. `addAnimation(layer, keyPath, { from, to, duration: ms / 1000, timing:
484
+ [x1, y1, x2, y2], delay }, key)`. CA renders `from` → `to` over the
485
+ model value and removes itself on completion, leaving the model showing
486
+ — the standard explicit-animation pattern, no `hold`.
487
+ 3. Timer for completion.
488
+
489
+ **Loops** are `repeat: Infinity` with `autoreverse` for `alternate`, and
490
+ cost **zero JS frames** — the spinner, the pulse and the indeterminate bar
491
+ become a render-server timer. The loop-stop rules still run (they are the
492
+ one truth about whether a loop may run), and each one is a
493
+ `removeAnimation`; a resume is a fresh `addAnimation`, phase reset, as the
494
+ JS loop does now. CA stops compositing an occluded window on its own, so
495
+ the rule costs nothing there and keeps the two backends identical in what
496
+ they promise.
497
+
498
+ **Interruption** — the "reverse from where it got to" property, which
499
+ test/style.test.js pins for the JS loop and which has to hold here:
500
+
501
+ - Numeric key paths (`opacity`, `transform.*`, `borderWidth`,
502
+ `cornerRadius`): **additive** animations, CA's own idiom. The model goes
503
+ straight to the new target; the animation is `from: old − new, to: 0,
504
+ additive: true`. Several in flight sum, so a retarget mid-flight is
505
+ continuous and carries its shape — with no readback of anything. This
506
+ needs the bridge's `additive` flag (§4.4).
507
+ - Colours (CA cannot add colours): the JS side computes where the running
508
+ animation is from its own clock and the same bezier — within a frame of
509
+ CA's truth, since both evaluate the same curve — and starts the next one
510
+ there. `presentationValue(layer, keyPath)` (§4.4) makes it exact later;
511
+ it is not on the critical path.
512
+
513
+ **What the presenter needs from the node** is what `_syncPropBox` already
514
+ reads plus the animated entry; the table above is a static map from style
515
+ property to key path plus a check of `visual.isRaster`. It is a small
516
+ addition to src/cocoa/presenter.js, and the cocoa-mock presenter in the
517
+ tests records `addAnimation` calls the same way it records `setLayerProps`.
518
+
519
+ ### 4.3 The surface presenter, and X11
520
+
521
+ X11 has nothing to offload: one bitmap per window, painted by the X11
522
+ walk, and the JS loop runs. The surface presenter started there too, and
523
+ now takes the same table as §4.2 by **layer promotion** (issue #483,
524
+ docs/macos.md §"Layer promotion"): a plain box with a transition or a loop
525
+ on an offloadable property gets a CALayer of its own above the window's
526
+ bitmap for as long as it animates, where nothing is painted over it, and
527
+ the same `LayerAnimations` books run it in the render server. The bitmap
528
+ keeps the frame for everything else. What this moved in the measure-first
529
+ gate's argument is recorded there, measured. `opacity` is in the table on
530
+ both presenters since #817, on a plain box: its layer is its group, since
531
+ Core Animation fades a layer and its sublayers as one, and promotion keeps
532
+ a box inside a faded one off a layer of its own, which would be outside
533
+ the group. The `Raster` column of §4.2 is not taken by either yet. An
534
+ element that draws its own scene hands over parts of it instead (#819,
535
+ `Node.sprites()`): each a layer under promotion's rules, its `opacity` and
536
+ `transform` animated as keyframes the element computed — a CSS transform
537
+ sampled into matrices, so that the element's interpolation, not Core
538
+ Animation's, is what the render server plays.
539
+
540
+ ### 4.4 Bridge additions (`@windowkit/appkit`)
541
+
542
+ In the order the offload needs them:
543
+
544
+ 1. **`timing: [x1, y1, x2, y2]`** on `addAnimation` and `txBegin` —
545
+ `CAMediaTimingFunction functionWithControlPoints:`. **Prerequisite**:
546
+ §3.1's numbers say the named curves would change every existing
547
+ transition's look.
548
+ 2. **`additive: true`** — one line on the `CABasicAnimation`. What makes
549
+ retargeting continuous without readback.
550
+ 3. **`delay`** — `beginTime = CACurrentMediaTime() + delay`, with
551
+ `fillMode: backwards` so the layer shows `from` during the delay.
552
+ 4. **Keyframes** — `values`, `keyTimes`, `timingFunctions` (one bezier per
553
+ segment), `calculationMode: linear` (a `CAKeyframeAnimation`).
554
+ 5. **Springs** — `CASpringAnimation` with `mass`/`stiffness`/`damping`/
555
+ `initialVelocity`, returning `settlingDuration` so JS can time completion.
556
+ 6. **`presentationValue(layer, keyPath)`** — `presentationLayer` readback;
557
+ exactness for colour retargets.
558
+ 7. **Completion callback** — the animation delegate's `animationDidStop:
559
+ finished:` forwarded through the event callback; makes
560
+ `onTransitionEnd` exact rather than timer-derived. Optional.
561
+ 8. **Reduce motion** — `NSWorkspace.accessibilityDisplayShouldReduceMotion`
562
+ plus its notification, into `desktopSettings().animations` on the Cocoa
563
+ app. Independent of this design and already wrong without it.
564
+
565
+ Each is a verb, not policy, which is the shape the bridge's contract asks
566
+ for (docs/macos.md, "The channel is already node-granular").
567
+
568
+ Filed as [windowkit/appkit#29](https://github.com/windowkit/appkit/issues/29);
569
+ [windowkit/appkit#30](https://github.com/windowkit/appkit/pull/30) implements
570
+ items 1–7 plus `speed`/`timeOffset` (merged); item 8 is
571
+ [windowkit/appkit#31](https://github.com/windowkit/appkit/issues/31), implemented
572
+ by [windowkit/appkit#32](https://github.com/windowkit/appkit/pull/32). Two
573
+ things the bridge work found that the presenter has to know: CA interpolates
574
+ colours in the display's space, so a colour read back mid-flight is not the
575
+ component midpoint; and a paused animation sampled at exactly its duration
576
+ reads as its start, so an end is sampled a millisecond early.
577
+
578
+ ## 5. What `opacity` and transforms cost on X11
579
+
580
+ Both are **group** operations on a subtree, and a group needs an offscreen:
581
+ per-op alpha on overlapping children double-blends where they overlap, and
582
+ per-op transforms would rotate each child about its own centre. So:
583
+
584
+ 1. Paint the subtree into an ntk `Surface` the size of `paintBounds()` in
585
+ device pixels — the same surface family the paint cache and #433's
586
+ `CocoaSurface` use, behind `app.createSurface` on the Cocoa surface
587
+ backend and ntk's pixmap on X11.
588
+ 2. Composite it back with `ctx.globalAlpha = opacity` and the transform set
589
+ on the context. ntk's `drawImage` already routes a transformed draw
590
+ through `SetPictureTransform` with a filter and scales the composite by
591
+ `globalAlpha` (node_modules/ntk/lib/renderingcontext_2d.js:2046,
592
+ :4599) — server-side, no readback. Rotation's filter quality and the
593
+ transformed bounding box are the two things to verify with a pixel test
594
+ rather than assume.
595
+
596
+ Per animated frame that is one pixmap the size of the subtree plus one
597
+ composite — bounded to the thing that moves, the way every claim here is.
598
+ Two fast paths cover most of what apps write and skip the offscreen
599
+ entirely:
600
+
601
+ - **A pure translate**: `ctx.translate` and paint the subtree through it.
602
+ Every child paints through the same context, clips included. That is
603
+ the shake, the slide, the entrance, the Switch thumb.
604
+ - **Opacity on a leaf** (a `<box>` with no children, or a `<text>`):
605
+ multiply the alpha into the fill, border and ink. One node, no group.
606
+
607
+ `opacity: 1` and identity transforms cost nothing, which keeps the
608
+ properties safe to leave in a style. The offscreen surface is per node,
609
+ kept between frames of one animation and dropped when it ends, and it is
610
+ the paint cache's budget that bounds it (src/paintcache.js's byte budget
611
+ is the pattern; the surface here is not content-keyed, it is a frame
612
+ scratch).
613
+
614
+ Damage: `_advanceAnimations` already claims the node before the tick, which
615
+ is the old bounding rect. A transform moves nothing in layout, so no pass
616
+ claims the new one for it: the tick claims that too, the pair a layout
617
+ transition gets from the layout pass's diff (`_assignAbs`).
618
+
619
+ ## 6. Where the code goes
620
+
621
+ - **src/styles.js** — `resolveEasing` (names → control points → a bezier
622
+ function; the spring solver), `transitionFor` → `{ duration, easing,
623
+ delay }`, `parseAnimation` (`keyframes`, `repeat`, `delay`, `key`; the
624
+ validation messages name the option and the property as now),
625
+ `animationValueAt` over a keyframe list (segment lookup, per-segment
626
+ easing, `repeat` as a cap on the cycle count), `sameAnimation` over the
627
+ new fields, and the six new properties in `PAINT_PROPS` — which puts
628
+ them in state blocks and out of `NOT_ANIMATABLE` for free.
629
+ - **src/nodes/** — `_retarget` (easing and delay on the entry; the
630
+ presenter seam), `_tickAnimations` (finite end → rest at the target,
631
+ completion events), `_updateLoops` (cancel through the presenter),
632
+ `paint` (the group path and its two fast paths around
633
+ `_paintBackground`/`paintContent`/`_paintChildren`), `paintBounds()`
634
+ and `_subtreeBounds()` (transformed boxes), `_advanceAnimations` (the
635
+ claim for where a transform moved the box).
636
+ - **src/events.js** — the inverse-mapped hit test.
637
+ - **src/cocoa/presenter.js** — `animate`/`cancel`, the key-path table,
638
+ `opacity` and `transform` on both visual kinds; the mock presenter
639
+ records them.
640
+ - **src/types/style.d.ts, src/style.d.ts, test/types/api.tsx** — `Easing`,
641
+ `Transition`, `AnimationSpec`, the six properties, the two events.
642
+ - **docs/styling.md** ("Transitions", "Loops" → "Timelines"),
643
+ **docs/macos.md** (the "Animations and transforms" section becomes a
644
+ pointer here plus the measured result), **docs/ecosystem/animation.md**
645
+ (delete the "no opacity" constraint; the page shrinks), **docs/system.md**
646
+ (reduced motion covers finite timelines), **docs/elements.md** (`scale`
647
+ vs `scaleX`, the events).
648
+ - **Widgets and examples** — Switch on `translateX`; Tooltip fades;
649
+ ProgressBar unchanged; a shake in the configurator's password field;
650
+ the website demos that use `transition` still pass `check-demos`.
651
+ - **Tests** — bezier table vs CSS samples and vs the polynomials
652
+ (test/style.test.js); finite timelines, keyframes, `key` restart, rest at
653
+ the declared value, `delay` (test/animation-loop.test.js); the mock
654
+ presenter: a `backgroundColor` transition on a PropBox schedules zero
655
+ frames and one `addAnimation` with control points, `color` on a `<text>`
656
+ falls back to the loop, a loop stop removes the animation; pixels: group
657
+ opacity over overlapping children against a reference composite, a
658
+ rotate against a pre-rotated reference, the translate fast path
659
+ byte-identical to the offscreen path (the differential oracle
660
+ test/dirty-rect.test.js already has, applied here); the hit test through
661
+ a transform. And a **structural** row in `scripts/bench/presenters-gate.json`:
662
+ JS frames scheduled per 120ms hover transition on layers — 0 with the
663
+ offload, ~8 without — the same kind of number the gate already judges.
664
+
665
+ ## 7. Sequencing
666
+
667
+ Each step ships on both backends and is useful alone; the offload is
668
+ additive on top and can proceed in parallel from step 1.
669
+
670
+ 1. **Easing vocabulary and the `transition` object form** (`all`, per-property
671
+ `{ duration, easing, delay }`). Small; the bezier solver and its table
672
+ test are most of it.
673
+ 2. **Timelines**: `keyframes`, `repeat`, `delay`, `key`, the completion
674
+ events, reduced motion over finite timelines.
675
+ 3. **`opacity`**: the group path with the leaf fast path on X11 and the
676
+ surface presenter; `opacity` on both visuals on layers.
677
+ 4. **Transforms**: translate first (fast path only, no offscreen — most of
678
+ the value for a fraction of the work), then rotate and scale over the
679
+ offscreen with the inverse hit test. Switch moves to `translateX`.
680
+ 5. **Layer offload**: bridge items 1–3 (`windowkit/appkit` PRs), then the
681
+ presenter seam and its mock tests, the gate row, and a measured
682
+ before/after on a real display the way docs/macos.md "Measured" records
683
+ them. Items 4–7 follow as the timeline and spring features reach the
684
+ presenter.
685
+ 6. **Springs**: the JS solver, bridge item 5, `easing: { spring }` on
686
+ transitions.
687
+
688
+ ## 8. Open questions
689
+
690
+ - **`key` vs `run`** for the restart token (§3.3). Cheap to decide, hard to
691
+ rename later.
692
+ - **Should a finite timeline snap or ease into the declared value** when its
693
+ last stop differs? This document says snap, because it is honest and CSS
694
+ does the same; the alternative — an implicit last stop at the declared
695
+ value — hides a mismatch behind a nicer default.
696
+ - **Additive colours.** CA cannot; the clock-estimated `from` is within a
697
+ frame, and the readback verb closes it. Decide whether a frame's error
698
+ on a 120ms colour retarget is ever visible before building the verb.
699
+ - **Transforms and text selection, carets, scroll**: a transformed
700
+ `<textinput>` maps its pointer through the inverse like everything else,
701
+ but its caret and selection geometry are computed in layout space. The
702
+ first cut may reject transforms on nodes that own an editing surface, or
703
+ accept a documented gap; a real display will say which.
704
+ - **Whether `opacity` on X11 should take the offscreen at all** for a
705
+ subtree above a size, or fall back to per-node alpha with the
706
+ double-blend documented. The paint cache's item cap (`MAX_ITEM_PIXELS`)
707
+ is the precedent for "past this size, do the simple thing".