react-x11 2.52.0 → 2.53.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 (112) hide show
  1. package/README.md +10 -10
  2. package/docs/README.md +160 -314
  3. package/docs/accessibility.md +176 -229
  4. package/docs/appearance.md +77 -176
  5. package/docs/click-to-component.md +18 -51
  6. package/docs/clipboard.md +40 -59
  7. package/docs/components.md +518 -1036
  8. package/docs/dbus.md +61 -133
  9. package/docs/debugging.md +152 -245
  10. package/docs/desktop-calendar.md +51 -88
  11. package/docs/desktop.md +222 -347
  12. package/docs/devtools.md +50 -167
  13. package/docs/drag-and-drop.md +126 -204
  14. package/docs/ecosystem/animation.md +91 -125
  15. package/docs/ecosystem/data-fetching.md +38 -53
  16. package/docs/ecosystem/dev-tooling.md +159 -202
  17. package/docs/ecosystem/forms.md +61 -97
  18. package/docs/ecosystem/headless.md +64 -89
  19. package/docs/ecosystem/i18n.md +18 -21
  20. package/docs/ecosystem/icons.md +93 -126
  21. package/docs/ecosystem/layout.md +42 -52
  22. package/docs/ecosystem/routing.md +18 -29
  23. package/docs/ecosystem/state.md +71 -88
  24. package/docs/ecosystem/testing.md +131 -157
  25. package/docs/ecosystem/theming.md +119 -165
  26. package/docs/ecosystem.md +145 -179
  27. package/docs/elements.md +1091 -1774
  28. package/docs/embedding.md +83 -132
  29. package/docs/events.md +412 -695
  30. package/docs/extending.md +784 -1326
  31. package/docs/eyedropper.md +63 -122
  32. package/docs/filedialog.md +71 -107
  33. package/docs/frame.md +195 -339
  34. package/docs/gl.md +65 -62
  35. package/docs/globalmenu.md +63 -146
  36. package/docs/macos.md +531 -3234
  37. package/docs/notifications.md +33 -47
  38. package/docs/packaging.md +166 -309
  39. package/docs/permissions.md +9 -14
  40. package/docs/react-features.md +81 -106
  41. package/docs/remote.md +32 -76
  42. package/docs/scale.md +72 -186
  43. package/docs/styling.md +329 -547
  44. package/docs/system.md +200 -280
  45. package/docs/testing.md +63 -83
  46. package/docs/typescript.md +6 -48
  47. package/docs/uri-schemes.md +67 -107
  48. package/docs/wayland-backend.md +200 -459
  49. package/docs/windows-integrations.md +75 -131
  50. package/docs/windows.md +343 -1367
  51. package/package.json +3 -2
  52. package/src/Reconciler.js +2 -2
  53. package/src/a11y.js +1 -1
  54. package/src/backend/context2d.js +3 -3
  55. package/src/bootstrap.js +1 -1
  56. package/src/cocoa/app.js +6 -6
  57. package/src/cocoa/bezels.js +1 -1
  58. package/src/cocoa/fonts.js +1 -1
  59. package/src/cocoa/globalmenu.js +1 -1
  60. package/src/cocoa/keymap.js +1 -1
  61. package/src/cocoa/panewindow.js +1 -1
  62. package/src/cocoa/player.js +1 -1
  63. package/src/cocoa/presenter.js +2 -2
  64. package/src/cocoa/promotion.js +1 -1
  65. package/src/cocoa/relaunch.js +1 -1
  66. package/src/cocoa/threaded.js +1 -1
  67. package/src/cocoa/video.js +1 -1
  68. package/src/cocoa/window.js +4 -4
  69. package/src/components/Menu.js +7 -2
  70. package/src/components/Switch.js +1 -1
  71. package/src/components/native.js +1 -1
  72. package/src/dnd.js +2 -2
  73. package/src/glnodes.js +1 -1
  74. package/src/grid.js +1 -1
  75. package/src/layouts.js +1 -1
  76. package/src/nodes/animation.js +1 -1
  77. package/src/nodes/invalidate.js +1 -1
  78. package/src/nodes/layouthost.js +1 -1
  79. package/src/nodes/video.js +19 -8
  80. package/src/nodes/window/droptarget.js +1 -1
  81. package/src/pacing.js +1 -1
  82. package/src/refresh/index.js +2 -1
  83. package/src/types/video.d.ts +5 -2
  84. package/src/videobackend.js +28 -0
  85. package/src/videoframes.js +29 -16
  86. package/src/wayland/app.js +27 -0
  87. package/src/wayland/video.js +376 -0
  88. package/src/win32/a11y.js +1 -1
  89. package/src/win32/app.js +2 -2
  90. package/src/win32/fonts.js +1 -1
  91. package/src/win32/glarea.js +4 -4
  92. package/src/win32/overlay.js +1 -1
  93. package/src/win32/panewindow.js +3 -3
  94. package/src/win32/shell.js +1 -1
  95. package/src/win32/window.js +2 -2
  96. package/src/windowid.js +1 -1
  97. package/src/xvideo.js +189 -0
  98. package/docs/architecture/accessibility.md +0 -470
  99. package/docs/architecture/animation.md +0 -707
  100. package/docs/architecture/container-queries.md +0 -525
  101. package/docs/architecture/custom-layout.md +0 -334
  102. package/docs/architecture/drag-and-drop.md +0 -941
  103. package/docs/architecture/element-layer-contents.md +0 -622
  104. package/docs/architecture/frame-pacing.md +0 -255
  105. package/docs/architecture/grid-layout.md +0 -358
  106. package/docs/architecture/protocol-efficiency.md +0 -357
  107. package/docs/architecture/video.md +0 -1149
  108. package/docs/architecture/windowed-regions.md +0 -854
  109. package/docs/glx.md +0 -137
  110. package/docs/wayland.md +0 -545
  111. package/docs/windows-embedding.md +0 -271
  112. package/docs/windows-gl.md +0 -221
package/docs/macos.md CHANGED
@@ -1,3295 +1,592 @@
1
1
  # A native macOS backend
2
2
 
3
- **Status: shipped, and still being extended.** This began life as a research
4
- RFC written 2026-09-01 against react-x11 2.2.1, and the backend it planned is
5
- now in the tree: `src/cocoa/` is ~15,400 lines across 33 modules, the bridge
6
- published as **[`@windowkit/appkit`](https://www.npmjs.com/package/@windowkit/appkit)**
7
- (an `optionalDependency`, `^0.30.1` as of react-x11 2.51.1), and
8
- `createRoot()` selects it **by default on macOS**. Where this document says
9
- `node-calayers`, read `@windowkit/appkit`: that is what the POC was called
10
- before it was published.
11
-
12
- What has landed, against §"The plan" at the foot of this page: **Phase 0**
13
- (the bridge contract) and **Phase 1** (the surface presenter, still the
14
- measured default), then most of **Phases 4 and 5** — the system menu bar,
15
- native control bezels, native file panels, notifications, appearance,
16
- screens and scale, the pasteboard, drag and drop, `<glarea>` over CGL,
17
- `NSStatusItem` and the Dock, Apple-Event app lifecycle,
18
- `useSupports('nativeControls')`, and layer promotion for animation.
19
-
20
- **Phase 2 did not happen, and that is the good news.** It proposed
21
- extracting X11's paint/damage/flush into a presenter behind a formal
22
- invalidate/flush contract, as an X11-neutral refactor pinned by the pixel
23
- gates. What the code does instead is cheaper and safer: the surface
24
- presenter _is_ the existing paint machinery, drawing into the window's
25
- bitmap, and the seam is a handful of **optional hooks feature-detected on
26
- the window object** — `presentFrame`, `noteInvalidate`, `scrollRegion`,
27
- `animateNode`, `cancelNodeAnimation` (`src/cocoa/window.js`, read at
28
- `src/nodes/animation.js`). A window that does not define them gets today's path
29
- byte for byte, which is the guarantee Phase 2 wanted without the refactor
30
- it asked for. Nothing is documented in `extending.md` terms because there
31
- is no interface to document.
32
-
33
- **Phase 3** is therefore partial rather than blocked: the full layer
34
- presenter is real (`src/cocoa/presenter.js`, `CocoaLayerPresenter` — one
35
- CALayer per drawn node) but stays behind `cocoa: { presenter: 'layers' }`
36
- while §"Measure first" is open, and layer promotion covers the animation
37
- case on the default path.
38
-
39
- **Not built**, and honestly the interesting remainder: an `NSAccessibility`
40
- bridge over the a11y model (on Cocoa `createRoot()` leaves the AT-SPI bridge
41
- off and puts nothing in its place), IME through `NSTextInputClient`, a
42
- `'cocoa'` backend for `react-x11/test`, `transform` as a style property,
43
- and the `Primary` chord token. §"Open questions" is still live,
44
- and the name question (§Public API #8) was answered by inaction: the package
45
- is still `react-x11`.
46
-
47
- The line counts and inventories below are measurements taken when this was
48
- written, not estimates; where a companion number comes from
49
- [wayland.md](wayland.md), it is cited rather than re-measured. Sections
50
- titled "Measured:" were added as the work landed and each names the commit
51
- it was measured at.
52
-
53
- ## What this is, and is not
54
-
55
- This is a plan for a **third target, second backend family**: the same
56
- React tree, the same components and the same application code running as a
57
- first-class macOS app — real `NSWindow`s, the system menu bar, native
58
- control rendering, Core Animation compositing — with no X server anywhere.
59
- X11 stays what it is: the flagship remote target ([remote.md](remote.md)),
60
- the window-manager story, and the only backend for `<foreign>`/XEmbed.
61
- Nothing here proposes changing how the X11 path works, and the plan is
62
- explicitly shaped so that its refactors are pinned by the existing pixel
63
- and bench gates before any macOS code lands on top of them.
64
-
65
- The relationship to the [Wayland RFC](wayland.md) matters, because the two
66
- inversions are opposite and one architecture has to hold both:
67
-
68
- - **Wayland deletes the drawing protocol downward** — the client
69
- rasterizes finished pixels. Its natural seam is _below_ ntk's 2d
70
- context: a span compositor behind the same `getContext('2d')` name.
71
- - **macOS deletes the drawing protocol upward** — the WindowServer is a
72
- retained compositor and the client's job is to _mutate a persistent
73
- layer tree_, not to encode draw calls and not to rasterize (except
74
- where it chooses to). Its natural seam is _above_ the 2d context: at
75
- the node tree itself.
76
-
77
- Both seams can exist at once, and the extraction this document proposes
78
- (§"One renderer, two presenters") leaves the Wayland plan intact — a
79
- Wayland presenter is the X11 presenter with a different surface under it.
80
-
81
- Goals, unchanged from the project's standing ones: perceived performance
82
- first (input-to-photon), then stability, then client CPU and memory; the
83
- whole range of desktop UI that the platform permits; a small portable
84
- codebase, with a native module admissible where the runtime leaves no
85
- other way to reach a required OS facility — and macOS is the clearest
86
- case of that clause: there is no wire protocol to speak, only
87
- frameworks, so the bridge is a compiled addon by necessity. What keeps
88
- the spirit of the rule is keeping the addon **thin and mechanism-only**
89
- (create/set/add/remove/pump — no policy, no widget logic), which is
90
- exactly the shape `node-calayers` already has.
91
-
92
- Out of scope on this backend, honestly and up front, the same way
93
- wayland.md declares its walls:
94
-
95
- - **A window manager cannot be written against Cocoa.** `examples/wm.jsx` and
96
- the substructure-redirect half of the API remain X11-only.
97
- - **Cross-process window embedding does not exist on macOS.** `<foreign>`, the
98
- XEmbed half of `<Frame>`, `@react-x11/components`' tray-host and its mpv/VLC
99
- `--wid` embedding have no equivalent. `<Frame>` on macOS was planned as a
100
- parented toplevel or an in-process pane, the fate wayland.md assigns it, and
101
- shipped as neither: its pane draws into IOSurfaces a host sublayer shows
102
- (`src/cocoa/panehost.js`, [frame.md](frame.md)). A `<foreign>` rendered here
103
- says so — one `onError`, no `onReady`, an empty box — and
104
- `useSupports('embedding')` is false, so a component can ask before rendering
105
- one (#531).
106
- - **XQuartz is not deprecated by this.** react-x11 already runs on macOS today
107
- through XQuartz; that path remains the way to run the X11 backend (and the
108
- WM example) on a Mac. This backend is for shipping _native_ apps.
109
-
110
- ## What macOS actually changes
111
-
112
- X11 is "the client encodes drawing, the server rasterizes and composites."
113
- Wayland is "the client rasterizes, the compositor composites." macOS is a
114
- third shape: **the client owns a retained scene graph (the CALayer tree)
115
- and the render server composites it on the GPU** — geometry, colors,
116
- corner radii, shadows, opacity, transforms, filters, and whole animations
117
- are _properties of persistent objects_, committed atomically in
118
- transactions and interpolated server-side without the client's further
119
- involvement.
120
-
121
- That shape is not an obstacle to this renderer; it is this renderer's own
122
- architecture, one level down. react-x11 is already a retained tree — every
123
- drawn element is a lightweight node holding style, a yoga node and an
124
- absolute rect, and React commits arrive as property diffs against it
125
- (`commitUpdate` → `applyProps(new, old)`). On X11 those diffs then have to
126
- be _flattened into paint_: damage rects, a paint walk, clip bounding,
127
- scroll blits, a paint cache — ~1,000 lines of machinery whose entire job
128
- is to turn retained-tree diffs back into efficient immediate-mode drawing.
129
- On macOS the diff **is** the drawing: a commit maps to one `CATransaction`
130
- of `layer.set(...)` calls, layout writes frames inside the same
131
- transaction, and the WindowServer recomposites what changed. The
132
- reconciler's native output format and Core Animation's native input format
133
- are the same thing — a property diff over a retained tree — with no
134
- serialization through an immediate-mode bottleneck between them.
135
-
136
- Three consequences are worth naming before any code, because they shape
137
- the whole plan:
138
-
139
- - **Animation leaves the JS thread.** A CA animation runs in the render
140
- server; the POC's spinner stays smooth while JS is stalled. The
141
- project's first goal is answering the input on the frame it arrived —
142
- a `transition:` on `:active` that the _system_ interpolates is that
143
- goal with the JS thread taken out of the loop entirely.
144
- - **Scrolling becomes a property.** The scroll-blit fast path — gates,
145
- ledger, claim clipping, `CopyArea` (AGENTS.md §protocol efficiency) —
146
- exists because scrolling on X11 is repainting. On a layer tree,
147
- scrolling is `bounds.origin` on one clipping layer: constant cost,
148
- server-side, and the entire fast-path apparatus does not need to exist
149
- in that presenter.
150
- - **Retina inverts from cost to freebie.** On the X11 path a 2× display
151
- quadruples every rasterized pixel; layers carry `contentsScale` and the
152
- GPU composites at native resolution, with rasterization confined to the
153
- nodes that actually rasterize (text, images, custom drawing).
154
-
155
- The other thing macOS changes is _expectation_: a Mac app has the menu bar
156
- at the top of the screen, native-looking controls, native open/save
157
- dialogs, ⌘-shortcuts. This project's answer to the same question on Linux
158
- was built desktop-first (D-Bus global menu with in-window fallback,
159
- portals, XSETTINGS), and this document keeps that stance: the menu bar
160
- becomes `NSMenu`, controls prefer native rendering with an escape hatch,
161
- dialogs go native — each behind the same "default that serves the main
162
- customer, seam for everyone else" rule the codebase already follows.
163
-
164
- ## What the POC already proves
165
-
166
- _Kept as written, because it is the record of what was de-risked before any
167
- of this was built. The package is published as `@windowkit/appkit` now, and
168
- every "known POC limit" at the foot of this section has since been closed._
169
-
170
- `node-calayers` (then local, `~/tmp/node-calayers`) settles the risky
171
- mechanisms, which is what a POC is for:
172
-
173
- - **A Node process can own NSApplication.** Node's main thread _is_ the
174
- process main thread on macOS; the addon initializes the app and JS
175
- drives an event pump (`nextEventMatchingMask:` with `distantPast`) off
176
- a timer. No `[NSApp run]`, no second process, no thread hop.
177
- - **Retained layers behave.** `Layer`/`TextLayer`/`GradientLayer`/
178
- `ShapeLayer` wrappers over real `CALayer`s: frame/bounds/position,
179
- backgroundColor, cornerRadius, borderWidth, shadows, opacity,
180
- zPosition, masks, `masksToBounds`, transforms — mutated with
181
- `layer.set(props)`, batched with `CATransaction`, suppressed with
182
- `disableActions` (the reconciler-commit mode), animated implicitly and
183
- explicitly (`CABasicAnimation` on any keyPath, running in the render
184
- server through a JS stall).
185
- - **Top-left coordinates work.** A layer-hosting, flipped `NSView` makes
186
- the hosted tree top-left-origin (`geometryFlipped`); the two flip
187
- gotchas (`hitTest:` stays bottom-up, `renderInContext:` ignores the
188
- flip) are handled in native code, and snapshots read the window's real
189
- composited pixels via `CGWindowListCreateImage` — no screen-recording
190
- permission needed for the process's own windows.
191
- - **CoreText measures and rasterizes.** `text.measure()` →
192
- `{width, ascent, descent, leading}` via `CTLine`;
193
- `text.render()` → CGImage via `CTFramesetter`, set as layer contents —
194
- the glyph path a text visual needs. `CATextLayer` works too, taking the
195
- WindowServer path for retina-crisp static text.
196
- - **Native control bezels render offscreen** — the WebKit/Firefox form
197
- technique: `NSButtonCell`/`NSPopUpButtonCell` via `drawWithFrame:`
198
- under `performAsCurrentDrawingAppearance:` (so dark mode and the user's
199
- accent apply), and the two controls modern AppKit refuses to draw as
200
- cells (`NSSlider`, `NSSwitch`) via a real unparented control's
201
- `displayRectIgnoringOpacity:`. Push (incl. accent-filled default),
202
- checkbox, radio, popup, slider, switch — interactive states, both
203
- appearances, re-renderable live.
204
- - **Events arrive.** Mouse/key/wheel from the real pump to a JS callback;
205
- synthetic event posting through the same pump for tests
206
- (`postMouseEvent`), which is the `fireEvent` transport a test harness
207
- wants.
208
- - **Hit testing round-trips.** `-[CALayer hitTest:]` maps back to JS
209
- wrapper objects by layer name. (react-x11 will not use it — the node
210
- tree already hit-tests — but it validates the tree's integrity.)
211
-
212
- Known POC limits, all of which are Phase-0 work items rather than
213
- unknowns: no modifier flags on events, one shared event callback with no
214
- per-window routing, no `NSWindowDelegate` (resize/close/focus arrive only
215
- by polling), sublayer append-only (no insert-at-index), no raw-buffer
216
- `contents` upload (only CGImages produced native-side), no menu API, no
217
- pasteboard, no drag session, no display-link frame clock, and the
218
- pump-on-a-timer model leaves AppKit's internal modal loops (live resize,
219
- menu tracking) starving JS. The full required API is specified in
220
- §"The bridge: the required API".
221
-
222
- ## What already carries over
223
-
224
- The measured split from [wayland.md](wayland.md) §"What already carries
225
- over" applies unchanged, because it was measured against the display
226
- system, not against Wayland specifically: **~16,200 lines of portable
227
- core** (reconciler, yoga, styles/decorations parsing, palette, a11y
228
- model, keysyms, compose, text-selection model, anchors) and **~8,700
229
- lines of `components/` + `frame/` + `refresh/`** carry over as-is. Of
230
- `nodes.js`'s 11,515 lines, only `WindowNode` + `PopupNode` (~3,100)
231
- speak X; `BoxNode`, `TextNode`, `ImageNode`, `CanvasNode`,
232
- `TextInputNode`, `TextAreaNode` and the layout/hit-test machinery never
233
- name the display system. The honest per-backend surface is the same
234
- ~9–10k-line list wayland.md names — window realize/present, events, DnD,
235
- clipboard, screens, window state, scale, keyboard state, compositing,
236
- input time, startup — plus, for macOS, the presenter itself.
237
-
238
- What the _consumers_ actually depend on was inventoried for this RFC
239
- across both sibling repos (`@react-x11/workbench` 0.1.0,
240
- `@react-x11/components` 0.4.0, cloned and grepped 2026-09-01):
241
-
242
- - The workbench is a thin consumer: `<box>`, `<text>`, `<window>`
243
- (`title`/`width`/`height`/`onCloseRequest` only), `<textinput>`, seven
244
- core components, `react-x11/test`, and
245
- `jsxImportSource: "react-x11"`. Nothing X-shaped at all.
246
- - `@react-x11/components` is the real contract: **all ten subpaths**
247
- (`.`, `/host`, `/node`, `/style`, `/keysyms`, `/test`, `/yoga`,
248
- `/ntk`, `/jsx-runtime`, `/jsx-dev-runtime`, `/debug`). It subclasses
249
- `Node` and overrides `paint(ctx)`/`measureContent`/`default*` in seven
250
- registered elements; it uses the ntk `Context2D` dialect **including
251
- the extensions** `fillRects`, `drawGlyphs`, `positioned`,
252
- `layoutSubtree`, `vw`/`vh`/`em`; it reads X keysyms (`XK_*`) in twenty
253
- files; and its tests name backends
254
- (`renderX11(..., { backend: 'mock' | 'xserver' | ... })`).
255
- - Its genuinely X-only reaches are few and localized: offscreen
256
- `ntk.Surface` allocation (charts, terminal, flow), `createWindow({id})`
257
- for an embedded client's title, the XEmbed `windowId` flow feeding
258
- mpv/VLC, `serverTime()`, and the tray host. Those stay X11-only
259
- features of those components, not portability obligations.
260
-
261
- Two conclusions fall straight out of that inventory. First, **the public
262
- API does not need a redesign to go native** — the compatibility surface
263
- is the intrinsics, the components, the style system, the hooks, the
264
- `Node`/`Context2D`/`registerElement` seam, the keysym vocabulary and the
265
- test harness, and every one of those is implementable on Cocoa. Second,
266
- **`XK_*` keysyms are the cross-platform key vocabulary by adoption**, not
267
- an X leak to be replaced: they are symbolic integers used by every
268
- consumer, `keysymOf()`/`ctrlChordLetter()` already abstract them, and the
269
- macOS input layer synthesizes them (Latin-1 keysyms are their code
270
- points, the rest via the `0x01000000 + UCS` rule plus a ~50-entry `kVK_*`
271
- table for arrows/function/editing keys). Renaming them would break every
272
- consumer to remove a prefix.
273
-
274
- Desktop integration repeats Wayland's pleasant surprise, further along:
275
- the appearance ladder **already has a macOS rung** (`appearance.js` reads
276
- `AppleInterfaceStyle`/accent through a long-lived `osascript` child), and
277
- so does the file dialog ladder. The native backend upgrades those rungs
278
- from `osascript` to real API and deletes nothing.
279
-
280
- ## One renderer, two presenters
281
-
282
- The user-facing question first — _conditional per-OS host components
283
- inside one reconciler, or a completely separate reconciler exposing the
284
- same public API?_ — because everything else hangs off it.
285
-
286
- **A separate reconciler is the wrong split, and the inventory above says
287
- why.** The public API that must stay identical is not a thin façade: it
288
- includes the retained `Node` contract itself (subclassed by consumers),
289
- the style system with its state blocks and transitions, the event
290
- synthesis rules ("answer the input" — press chains, `:active`
291
- narrowing, capture/bubble order), focus and tab order, text editing with
292
- undo/coalescing, selection, scroll semantics, the a11y model, the anchor
293
- math, and the test harness's behavioral assumptions. A second
294
- implementation of all of that is a second toolkit wearing the first one's
295
- types — two copies of every behavioral decision AGENTS.md records, one
296
- maintainer, and a drift surface the size of the project. The measured
297
- portable core (~25k lines with components) is precisely the code a
298
- separate reconciler would fork.
299
-
300
- **A pure "conditional host components" split is too shallow**, though: the
301
- difference between X11 and Core Animation is not _which_ box node to
302
- instantiate, it is _what happens after commit_ — immediate-mode paint
303
- versus retained-property sync. The seam has to sit at that boundary, and
304
- the codebase is already shaped for it in three load-bearing ways:
305
-
306
- 1. `createInstance` performs **no platform calls** (the render phase is
307
- discardable); every real window is created in the commit phase via
308
- `realize()`. The host config is already platform-free.
309
- 2. Nodes do not paint themselves spontaneously — they **announce**:
310
- `invalidate(layoutChanged, node, reason)` is the one channel through
311
- which every change reaches the screen, and `WindowNode.flush()` is the
312
- one consumer. The X11 damage/paint pipeline is an _implementation of
313
- that channel_, not part of the node model.
314
- 3. Everything a node needs from the platform is already reached through
315
- the `app` object — `createWindow`, `fonts.layout`, `clipboard`, the
316
- event emitter — and `createRoot({ app })` injects it. The headless
317
- mock in `src/testing/mock-app.js` passes the whole suite, which is
318
- the existence proof that the contract is closed.
319
-
320
- So the proposal is **one renderer, one node model, two presenters**, plus
321
- per-platform service modules behind the existing hook APIs:
322
-
323
- | seam | X11 implementation (today's code) | Cocoa implementation |
324
- | --------------- | -------------------------------------------------------------- | ------------------------------------------------------------- |
325
- | **WindowHost** | `WindowNode.realize()` → ntk `CreateWindow`, WM hints, mapping | `NSWindow` + layer-hosting flipped view; delegates → events |
326
- | **Presenter** | damage rects → paint walk → ntk 2d ctx → XRender | dirty nodes → per-node visuals → `CATransaction` property set |
327
- | **TextEngine** | ntk `FontManager.layout` (fontkit shaping, glyph atlas) | CoreText (`CTFramesetter`/`CTLine`) behind the same interface |
328
- | **InputSource** | ntk window events (X11, XI2) | NSEvent pump → the same normalized event shapes |
329
- | **FrameClock** | ntk rAF (Present completions + fence) | `CADisplayLink`/`CVDisplayLink`; CA transactions for pacing |
330
- | **services** | screens/scale/appearance/clipboard/DnD/menus/… (X + D-Bus) | NSScreen/backingScale/NSAppearance/NSPasteboard/NSMenu/… |
331
-
332
- The refactor this requires of the X11 code is bounded and mechanical:
333
- `WindowNode.flush()`'s paint half and the `_paint*` methods become the
334
- X11 presenter (moved, not rewritten), and the node model keeps layout,
335
- absolutize, hit testing, events, focus, selection and props. It lands as
336
- its own phase with **zero behavior change**, pinned by the existing
337
- pixel tests, `test/dirty-rect.test.js`, and `npm run bench -- --check` —
338
- the same discipline the scroll-blit work used. Registered third-party
339
- elements keep their `paint(ctx)` contract on both presenters (see
340
- §"Custom drawing on a layer tree").
341
-
342
- This split also answers the "complete rewrite vs seams" question with
343
- numbers: seams cost a ~3–4k-line mechanical extraction inside `nodes.js`
344
- plus per-backend modules that had to be written under any design; a
345
- rewrite duplicates ~25k lines of accumulated behavior and every future
346
- fix twice. And the extraction is not macOS-only spend — the Wayland
347
- plan's "WindowNode.realize grows a second path" and the presenter split
348
- are the same seams.
349
-
350
- ## Two tiers, one architecture — and the reconciler fit
351
-
352
- Both integration levels from the exploration brief are real, and the plan
353
- uses both — but **they are stages of one build, not alternatives**, and
354
- the design target is the retained tier. The commitment made here, so the
355
- bring-up stage cannot calcify into the architecture: _Tier S exists to
356
- validate mechanisms and to serve as the raster fallback inside Tier L;
357
- every interface is designed for Tier L from day one._
358
-
359
- ### Tier S — surface presenter (canvas2d, single layer per window)
360
-
361
- Implement the `app`/`window`/`ctx` contract (the `mock-app.js` shape)
362
- over Cocoa: an `NSWindow` whose content is one bitmap-backed layer, a
363
- canvas2d context over `CGContext`, CoreText behind `app.fonts`, NSEvents
364
- mapped to the ntk event names. `createRoot({ app: cocoaApp })` then runs
365
- today's renderer unchanged — damage rects, paint walk, dirty-rect
366
- machinery and all, painting into the bitmap and committing changed rects
367
- to the layer (`setNeedsDisplayInRect`-style partial uploads, or an
368
- IOSurface pair).
369
-
370
- Everything here is load-bearing later, which is what justifies building
371
- it first:
372
-
373
- - the **CG-backed 2d context** is Tier L's raster fallback for
374
- `<canvas>`, `<svg>` and registered elements (CG is canvas2d's ancestor;
375
- the dialect including `fillRects` maps to `CGContextFillRects`
376
- directly);
377
- - the **CoreText TextEngine** is the same object Tier L's text visuals
378
- measure and rasterize through;
379
- - the **window host, event mapping and run-loop work** carry verbatim;
380
- - the whole thing runs the existing test suite and examples on macOS
381
- natively, which is the fastest possible detector for event/text/input
382
- contract gaps.
383
-
384
- What Tier S cannot do, structurally: make scrolling cheap (the blit
385
- becomes a `memmove` + upload), or exploit retina (2× is 4× the raster
386
- and 4× the upload). It is a correct port with X11's cost model on
387
- hardware that offers a better one. Offloading an animation it can — not
388
- structurally, but for the few nodes that animate, which get a layer of
389
- their own above the bitmap (§"Layer promotion" below, issue #483).
390
-
391
- **A rounded clip stays off CoreGraphics' path clip.** CG draws through a
392
- non-rectangular clip by masking everything the drawing covers: a pane-sized
393
- picture through a `roundRect` clip took 22 ms where the same picture under a
394
- rectangle took 8. So a box with `overflow` and a `borderRadius` draws its
395
- children under its rectangle and puts the corner squares they reached back,
396
- the route X11 has taken since #685 (`src/nodes/roundclip.js`). The bitmap is
397
- CPU memory, so keeping a corner is a row memcpy out of it
398
- (`BackendContext2D.readbackSource`, #693), and a graph's zoom inside a
399
- rounded pane went from 34 to 52 frames a second.
400
-
401
- ### Tier L — layer presenter (retained, the design target)
402
-
403
- The Cocoa presenter consumes the same `invalidate()` stream but keeps a
404
- **visual** per drawn node — lazily, only for nodes that draw something —
405
- and on flush syncs dirty nodes into layer properties inside one
406
- `CATransaction` per frame, actions disabled by default. Layout runs
407
- exactly as today (yoga, absolutize); frames are written from the yoga
408
- rects. The commit pipeline becomes:
409
-
410
- ```
411
- React commit → applyProps diff → invalidate(node)
412
- frame: yoga layout → for dirty nodes: visual.sync(node) → CATransaction commit
413
- ```
414
-
415
- No damage rects, no paint walk, no paint cache, no scroll-blit ledger, no
416
- `_outsideDamage` culling — that machinery is the X11 presenter's private
417
- business. The style vocabulary maps almost embarrassingly well, which is
418
- not a coincidence — both vocabularies are children of CSS:
419
-
420
- | style prop | CALayer mapping |
421
- | --------------------------------------- | --------------------------------------------------------------------------------- |
422
- | `backgroundColor` | `backgroundColor` |
423
- | `borderRadius` | `cornerRadius` (+ `cornerCurve: continuous` — a native nicety X11 cannot offer) |
424
- | uniform `border*` | `borderWidth`/`borderColor` |
425
- | per-edge border colors, `borderStyle` | four edge sublayers, or the raster fallback for that node |
426
- | `backgroundImage: linear-gradient(...)` | `CAGradientLayer` (the parsed spec from `decorations.js` feeds it directly) |
427
- | `boxShadow` | `shadowColor/Opacity/Radius/Offset` + **`shadowPath`** set from the rounded rect |
428
- | `zIndex` | `zPosition` (+ sibling order for equal z) |
429
- | `outline*` (focus ring) | one sublayer outside bounds |
430
- | `overflow: hidden`/scroll clipping | `masksToBounds` on the clipping node's layer |
431
- | scroll offset | `bounds.origin` on the clip layer — the whole scroll path |
432
- | `:hover`/`:active`/`:focus` state block | property sets on one layer (exactly the "repaint of one node" they were built as) |
433
- | `transition: { prop: ms }` | `CATransaction` duration / `CABasicAnimation` — **runs in the render server** |
434
-
435
- The `shadowPath` row carries a scar worth transferring: issue #413 was a
436
- blur re-run per composite that cost 700× and showed up in no request
437
- count. CA has the same trap in the same place — a shadow without
438
- `shadowPath` makes the render server derive the silhouette per frame —
439
- and the fix is the same shape: hand it the geometry once. The presenter
440
- sets `shadowPath` always, from the same resolved rect + radius the
441
- border uses.
442
-
443
- Nodes whose visual exceeds the property vocabulary do not fail — they
444
- **degrade to raster per node**: the visual allocates a bitmap, replays
445
- the node's paint through the CG-backed 2d context (translated so
446
- `this.abs` lands at the layer origin), and sets it as `contents`. That
447
- one rule carries `<canvas onDraw>`, `<svg>`, all seven of
448
- `@react-x11/components`' registered elements, per-edge borders and any
449
- future paint feature ahead of its layer mapping — correctness never
450
- waits for a mapping, and mappings are pure optimization.
451
-
452
- Two structural decisions inside Tier L, called out because they are easy
453
- to get wrong:
454
-
455
- - **Layer per drawing node, not per node.** A layout-only `<box>` (no
456
- background, border, shadow, clip, or scroll) materializes no layer;
457
- its children parent to the nearest ancestor with one, positioned by
458
- the already-absolute rects. A clipping or scrolling node always
459
- materializes (it _is_ the mask/viewport). This keeps the layer count
460
- proportional to visible painted content — the same quantity the X11
461
- path rasterizes — rather than to tree shape, and virtualized lists
462
- (`Table`, `monitor`) already bound visible content by construction.
463
- Whether collapsing is needed at all is a Phase-3 measurement
464
- (`CATransaction` commit cost vs. layer count), and the fallback of
465
- "every drawn node gets a layer" is the simpler first cut.
466
- - **The node tree stays the source of truth for input.** Hit testing,
467
- capture/bubble, press chains, focus — all continue to run on node
468
- rects exactly as on X11. `CALayer hitTest:` is not used for dispatch.
469
- One event model, one set of tests, identical interaction semantics on
470
- both backends; the layer tree is write-only presentation.
471
-
472
- Text in Tier L is a raster visual by default: the TextEngine's layout
473
- (same object that measured for yoga) rasterizes into the node's layer
474
- contents at `contentsScale`, so measurement and pixels cannot disagree.
475
- `CATextLayer` (attributed strings, WindowServer-side re-raster on scale
476
- change, animatable `foregroundColor`) is an _optional_ fast path for
477
- static single-style text, adopted only if profiling says the raster
478
- visuals' memory or update cost matters — it uses its own framesetter,
479
- and two framesetters is one framesetter too many for anything
480
- selection- or caret-bearing.
481
-
482
- ### Why the retained tier is the design target, in this project's terms
483
-
484
- The project grades itself on input-to-photon, then stability, then client
485
- CPU (AGENTS.md, [wayland.md](wayland.md) goals). Per scenario, against
486
- Tier S on the same hardware:
487
-
488
- - **Hover/press feedback** (`:hover`/`:active` blocks): Tier S repaints
489
- the control's rect into the bitmap and uploads; Tier L sets properties
490
- on one layer. Both answer on the input's frame; L does it with less
491
- work and _keeps doing it_ when the JS thread is busy mid-transition.
492
- - **Scrolling a 500-row list**: S = memmove + strip repaint + upload per
493
- notch (the X11 fast path, minus the server doing the blit for us);
494
- L = one `bounds.origin` write. The entire class of scroll-blit gates,
495
- ledgers and bail conditions stops existing on this backend.
496
- - **Transitions** (Switch thumb, hover fades): S = JS frame loop ×
497
- raster × upload for the duration; L = one animation handed to the
498
- render server, frame-perfect under JS stalls. This is the qualitative
499
- gap — no amount of Tier S optimization reaches "smooth while the app
500
- thread is blocked".
501
- - **Window live-resize**: S re-lays-out and re-rasters everything per
502
- tick on the modal loop's cadence; L re-lays-out and updates frames —
503
- the WindowServer interpolates the rest. (Both need the run-loop work
504
- in §"Input and the run loop" to receive resize ticks at all.)
505
- - **Typing**: equivalent — caret/selection damage is small either way;
506
- L still wins on retina by shipping properties instead of pixels.
507
-
508
- And one honest cost column: Tier L pays **per-layer memory** for
509
- rasterized contents (text bitmaps especially — X11's shared glyph atlas
510
- has no direct equivalent; mitigations if measurement demands them: an
511
- atlas texture behind `contents`+`contentsRect`, or `CATextLayer`),
512
- **commit overhead** proportional to layers mutated per transaction (kept
513
- off the NAPI floor by the batched-ops call in the addon API), and
514
- **complexity in the visuals** (edge sublayers, gradient layers) that the
515
- raster fallback bounds.
516
-
517
- ### Measure first — the gate between the tiers
518
-
519
- Per the exploration brief's own instruction: the retained tier is built
520
- because the model fits, but it is _kept_ because the numbers say so. The
521
- macOS twin of `scripts/bench` (Phase 1 exit) runs the same scenarios —
522
- hover storm, wheel scroll on the 500-row list, virtualized re-slice,
523
- transition storm, typing — and reports, per scenario: wall-clock frame
524
- time, **input-to-photon** (NSEvent timestamp → the display-link
525
- timestamp of the frame containing the response; CA has no
526
- presentation-time protocol, but a display link plus
527
- `CATransaction.completionBlock` brackets it within a frame), CPU time,
528
- and RSS. Tier S is the baseline; Tier L must beat it where the model
529
- says it should (scroll, transitions, resize) and must not regress
530
- typing or cold mount. If a scenario shows Tier S already saturating the
531
- display's refresh with headroom on target hardware, that scenario's
532
- Tier-L machinery (e.g. layer collapsing) is deferred — measured, not
533
- assumed, in both directions. The numbers land in this document's
534
- successor the way wayland.md carries its measurements.
3
+ On macOS `createRoot()` renders through **Cocoa** by default: real
4
+ `NSWindow`s, Core Animation compositing, the system menu bar and native
5
+ control bezels, with no X server anywhere. The tree, components, styles and
6
+ hooks are the same; this page is what an app sees differently here, and what
7
+ it does not have yet. The design record — presenters, threads, the bridge
8
+ contract, measurements, the plan — is [design/macos.md](../design/macos.md).
9
+
10
+ ## Choosing the backend
11
+
12
+ `createRoot({ backend })`:
13
+
14
+ - `'auto'` (the default) — Cocoa on macOS where the bridge is installed, X11
15
+ through `$DISPLAY` (XQuartz) where it is not.
16
+ - `'cocoa'` — Cocoa, with the bridge **required**.
17
+ - `'x11'` — X11 through XQuartz, still the way to run X-only features
18
+ (`<foreign>`, `examples/wm.jsx`) on a Mac.
19
+
20
+ `REACT_X11_BACKEND=x11` or `=cocoa` overrides both. `createRoot({ app })`
21
+ ignores `backend`: a connection already is one.
22
+
23
+ The bridge is **[`@windowkit/appkit`](https://www.npmjs.com/package/@windowkit/appkit)**,
24
+ an `optionalDependency` with N-API prebuilds for arm64 and x64, so it works
25
+ under Node and Bun. An install that skips optional dependencies has no
26
+ bridge, and `'auto'` falls back to X11. The range react-x11 asks for has
27
+ everything on this page; where an older bridge lacks a verb, the feature is
28
+ detected and degrades as described below.
29
+
30
+ ## Root options and environment
31
+
32
+ `createRoot({ cocoa: { … } })`, ignored off macOS and when `app` is passed:
33
+
34
+ - `presenter` — `'surface'` (the default: one bitmap per window) or
35
+ `'layers'` (one CALayer per drawn node, opt-in while it is measured).
36
+ `REACT_X11_COCOA_PRESENTER` says the same.
37
+ - `promote` — [layer promotion](#layer-promotion-the-animated-few-on-their-own-layers-above-the-surface-presenter)
38
+ of animated boxes; `false` keeps every animation on the frame clock.
39
+ `REACT_X11_COCOA_PROMOTE=1`/`=0` says the same.
40
+ - `frameInterval` — ms between scheduled frames. By default each window
41
+ paces itself on its own display (8.3 ms at 120 Hz, 16.7 at 60); a number
42
+ applies to every window.
43
+ - `pumpInterval` — ms between AppKit event pumps (8) when the app's JS runs
44
+ on the main thread: the floor under input latency there. A
45
+ [worker](#js-on-a-worker-a-ui-thread-of-the-bridges-own) has no pump.
46
+ - `resizeWait` — ms AppKit may hold a live-resize tick for the app's frame at
47
+ the new size, on a worker (50; `0` lets the edge move without waiting).
48
+ - `paneStretch` — when a `<Frame>` pane's last frame is stretched while its
49
+ next one comes: `'behind'` (the default: once it is later than
50
+ `resizeWait`), `'never'` or `'always'`. `REACT_X11_COCOA_PANE_STRETCH` says
51
+ the same.
52
+ - `screenPoll` — ms between re-reads of the screen layout while a
53
+ `useScreens()` subscriber is mounted (500; `0` for never).
54
+ - `appName` — what the Dock, ⌘-Tab and the app menu print for an unbundled
55
+ process; a bundle's `Info.plist` wins.
56
+ - `activationPolicy` — `'regular'` (the default: a Dock tile and a ⌘-Tab
57
+ entry), `'accessory'` (a menu-bar app: windows, no tile) or `'prohibited'`.
58
+ On a worker, set it [at launch](#js-on-a-worker-a-ui-thread-of-the-bridges-own).
59
+ - `exitOnQuit` — end the process once a quit request (the Dock's Quit, ⌘Q, a
60
+ logout) has closed the app (`true`); `false` is for an embedder that owns
61
+ the process's lifetime.
62
+
63
+ Other variables: `REACT_X11_THREADED=0` (keep the JS on the main thread),
64
+ `APPKIT_ACTIVATION_POLICY` (the policy AppKit launches with),
65
+ `REACT_X11_CALAYERS_PATH` (where the bridge's addon is, for a
66
+ [compiled binary](#running-as-an-app-bundle)), and
67
+ [`REACT_X11_NO_STROKE_CHUNKING=1`](#stroking-a-path-with-many-subpaths) and
68
+ [`REACT_X11_NO_SHADOW_TILES=1`](#a-shadow-from-a-tile).
69
+
70
+ The `desktop` option defaults to `{ a11y: false, globalMenu: false }` here:
71
+ AT-SPI and the D-Bus menu export have nothing to register against on a Mac.
72
+ The menu bar is [the system's own](#menus-the-global-menu-finally-at-home)
73
+ regardless.
535
74
 
536
75
  ## Windowing semantics: what maps, what bends, what breaks
537
76
 
538
- | react-x11 today | macOS | verdict |
539
- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
540
- | `<window>`, WM-managed | `NSWindow` (titled), delegate events for move/resize/close/focus | maps; the app _can_ place itself (unlike Wayland) |
541
- | `<window x y>` screen position | `setFrameOrigin` (flip Y against the screen frame) | maps |
542
- | auto-sizing (`width: 'auto'`) | measure first, then size the window before ordering front — same commit-phase shape as `realize()` | maps cleanly |
543
- | `<popup>` (override-redirect, self-placed) | borderless non-activating `NSPanel`, `addChildWindow` to the anchor's window, screen-coordinate placement | maps — `anchor.js` flip/clamp math survives against `NSScreen.visibleFrame` |
544
- | `<popup grab>` dismissal | no pointer grabs; local+global event monitors, or key-window resignation | bends — same UX, different mechanism |
545
- | `decorations: false` | `styleMask: [.borderless]` (or `.fullSizeContentView` for CSD-ish looks) | maps |
546
- | `states`: maximized/fullscreen/minimized/attention | `zoom:`, `toggleFullScreen:`, `miniaturize:`, `requestUserAttention:`; readback via delegate notifications | maps; `sticky`/`below`/`skip_taskbar`/`shaded` have no equivalent |
547
- | `alwaysOnTop` | `window.level = .floating` | maps |
548
- | `transientFor` | `addChildWindow:` / sheets | maps for the dialog case |
549
- | `appId`, `onClientMessage`, size-increment hints | — | gone; bundle identity lives in Info.plist |
550
- | `transparent` | `isOpaque = false`, clear background — always composited, no compositor probe needed | maps _better_; `useSupports('transparency')` is constant-true |
551
- | frame clock (Present + fence + estimator) | `CADisplayLink` (macOS 14+) / `CVDisplayLink` | simpler; per-window, refresh-rate-aware |
552
- | scale ladder (env → XSETTINGS → Xft → RandR) | `backingScaleFactor` + `windowDidChangeBackingProperties` | collapses to one authoritative, live source |
553
- | screens (`useScreens`) | `NSScreen.screens`, re-read: the bridge follows the change notification, but no event reaches a client | maps — asked before a placement and on a clock while watched (#617) |
554
- | appearance ladder | `NSApp.effectiveAppearance` KVO + `NSColor.controlAccentColor`; accent-change notification | the osascript rung already feeds accent + ink into the palette; in-process later |
555
- | clipboard (selections, INCR, targets) | `NSPasteboard` (+ lazy providers for the ownership model); no PRIMARY selection | maps; `transfer.js` MIME plumbing reusable; INCR dies unmourned |
556
- | DnD (XDND) | `NSDraggingSource`/`NSDraggingDestination` on the hosting view | maps; the `dropAccept`/`onDrag*` prop contract holds |
557
- | global menu (D-Bus registrar, in-window fallback) | `NSApp.mainMenu` — **always present**, delegation never fails | maps _better_; see §Menus |
558
- | file dialogs (portal → osascript → drawn) | `NSOpenPanel`/`NSSavePanel` replace the osascript rung | upgrades |
559
- | a11y (AT-SPI over D-Bus) | `NSAccessibility` protocol, virtual `NSAccessibilityElement` tree fed from the same `a11y.js` model | maps; the model carries, the bridge is new (the Chromium/Flutter shape) |
560
- | idle / keep-awake | `CGEventSourceSecondsSinceLastEventType` / `IOPMAssertion`, `NSProcessInfo` activity | idle **shipped**, `ioreg` until the bridge reads it (#938); keep-awake maps |
561
- | startup notification, `activateWindow` | `NSApp.activate` + `makeKeyAndOrderFront:`, `NSRunningApplication`; Launch Services owns launch UX | `activateWindow` **shipped** (`CocoaApp.raiseWindow`); startup dissolves |
562
- | URI schemes / single instance (`application.js`) | Apple Events (`kAEGetURL`) + Launch Services registration; single-instance is the platform default | maps; same hooks (`onAppOpen`/`onAppActivate`), new plumbing |
563
- | tray | `NSStatusItem` | **shipped** (`useTray()`); the X11 side has the freedesktop StatusNotifierItem rung (`src/statusnotifier.js`) |
564
- | `<glarea>` (GLX / direct CGL) | **shipped**: `x11-dri`'s CGL context rendering into IOSurfaces on a sublayer (`src/cocoa/glarea.js`) | bends; ntk's `cgl` direct backend was the prior art |
565
- | `<foreign>`, XEmbed `<Frame>`, `examples/wm.jsx` | — | **gone by design** |
566
- | `ssh -X` remoting | — | X11 backend remains the remote answer |
567
- | keyboard state (Caps Lock before first key) | `NSEvent.modifierFlags` (static read + `flagsChanged`) | maps |
568
-
569
- The popup row deserves the same sit-down wayland.md gave it, with the
570
- opposite conclusion: macOS _keeps_ client-side placement, so `anchor.js`
571
- stays the single source of truth on this backend — the math that had to
572
- move into Wayland's positioner stays exactly where it is here, reading
573
- `NSScreen` geometry instead of Xinerama.
77
+ macOS has no separate window manager, so what one does is where the backends
78
+ part company. The per-prop list is
79
+ [elements.md](elements.md#on-the-cocoa-backend); in short:
80
+
81
+ - **Maps**: `<window>` is an `NSWindow` that places itself (`x`/`y` work),
82
+ min/max sizes are its own, and its size is a whole number of points,
83
+ rounded up. `resizable` and `decorations` are read once, at creation.
84
+ `fullscreen`, `maximized`, `hidden` and `demands_attention` are the
85
+ full-screen Space, the zoom, the Dock and the Dock bounce.
86
+ - **Better**: `transparent` and a window's `borderRadius` always work —
87
+ every window composites, so `useSupports('transparency')` is always true.
88
+ - **Inert**: the `sticky`, `below`, `skip_taskbar` and `shaded` states;
89
+ `appId`, `windowType`, `gravity` and the increment and aspect hints (bundle
90
+ identity lives in `Info.plist`); `transientFor`, for now — a managed
91
+ `<Dialog>` floats on its own window anyway. `onClientMessage` never fires.
92
+ - **`<popup>`** is a borderless panel at the popup level, placed by the same
93
+ anchor math. `grab` bends: there are no pointer grabs, so a press in
94
+ another of the app's windows reaches the popup as a press outside it,
95
+ which dismisses it. `grabKeyboard` makes a key-capable window, as
96
+ `NSPopover` does, which closes on `onBlur` ([elements.md](elements.md)).
97
+ - **`<glarea>`** is a CGL context rendering into IOSurfaces on a sublayer
98
+ ([gl.md](gl.md)); **`<Frame>`** works through shared surfaces rather than
99
+ reparenting ([frame.md](frame.md)).
100
+ - **Screens** (`useScreens`) map, but macOS sends a client no event for a
101
+ display change, so the layout is re-read on a clock while watched
102
+ ([system.md](system.md#on-macos-the-layout-is-asked-for-not-announced)).
103
+ `app.scale` is the screen's `backingScaleFactor`, read once, at startup.
104
+ - **Not yet**: nested `<window>` elements throw.
105
+ - **Gone by design**: `<foreign>` and XEmbed, a window manager
106
+ (`examples/wm.jsx`), `ssh -X`. A `<foreign>` reports one `onError` and
107
+ draws an empty box; `useSupports('embedding')` is false.
108
+
109
+ **Inert means inert, not fatal, and silent**: a prop with no meaning here is
110
+ accepted and does nothing, so the same JSX runs on every backend and shared
111
+ application code stays branch-free.
574
112
 
575
113
  ## Desktop integrations: what an app does outside its own windows
576
114
 
577
- The handful of things an app says to the desktop rather than draws — a file
578
- dialog, a permission prompt, a drag to another app, a tray icon, a Dock badge,
579
- a raise from a launcher. This is the survey of where each stands on the cocoa
580
- backend, what already works (including the crude `osascript`/shell rungs that
581
- need no bridge), and what is blocked on a native addition — each gap has a
582
- `@windowkit/appkit` ticket for the bridge and a react-x11 ticket for the
583
- consumer, so the work is tracked rather than rediscovered.
584
-
585
- | integration | today on cocoa | the better way (bridge gap) | tickets |
586
- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------- |
587
- | **file open/save** | **shipped** — `NSOpenPanel`/`NSSavePanel` as a sheet, the ladder's top rung (`docs/filedialog.md`) | — (bridge 0.5) | react-x11#461 → windowkit/appkit#14 (done) |
588
- | **raise / app switcher** | **shipped** — `activateWindow()` = `NSApp.activate` + order-front | — | this PR (`CocoaApp.raiseWindow`) |
589
- | **deep links** (`useAppOpen`) | **shipped** — Apple Events → `useAppOpen`/`useAppActivate`; Quit → the primary window's close request | — (bridge 0.5) | react-x11#465 → windowkit/appkit#18 (done) |
590
- | **drag & drop** (external) | **shipped** — `NSDraggingDestination`/`Source` behind the same `dropAccept`/`onDrag*`/`dragData` props | — (bridge 0.5) | react-x11#462 → windowkit/appkit#16 (done) |
591
- | **clipboard** | **shipped** — `useClipboard()` and the text controls, over `NSPasteboard.general` as `CLIPBOARD`: every type in `targets()`/`read()` (text by the bridge, the rest by `osascript` until it reads items), `watch()` polling `pasteboardChangeCount()`. `PRIMARY` is X11-only, not a missing rung: selecting copies nothing (`docs/clipboard.md`) | `pasteboardItems()`, `pasteboardItemData(i, type)` | react-x11#937 → windowkit/appkit (to file) |
592
- | **system tray** | **shipped** — `useTray()` over `NSStatusItem` (the freedesktop half landed too, #353) | — (bridge 0.5) | react-x11#463 (macOS half of #353) → windowkit/appkit#17 (done) |
593
- | **Dock badge / attention / menu** | **shipped** — `setBadge`/`useBadge` (LauncherEntry on Linux), `demands_attention` bounces, `useLauncherMenu` | — (bridge 0.5) | react-x11#464 → windowkit/appkit#15 (done) |
594
- | **activation policy / app name** | **shipped** — `createRoot({ cocoa: { activationPolicy, appName } })` | — (bridge 0.5) | react-x11#464 → windowkit/appkit#15 (done) |
595
- | **permissions** (TCC) | **shipped** — `usePermission()`/`requestPermission()` over the bridge; `openPrivacySettings` on any Mac | — (bridge 0.5); Linux portals are #466's other half | react-x11#466 → windowkit/appkit#19 (done) |
596
- | **notifications** | **shipped** — `notify()` over `UNUserNotificationCenter` in a bundle, `osascript` for a bare `node`; `org.freedesktop.Notifications` on Linux | — (bridge 0.5) | react-x11#469 → windowkit/appkit#26 (done) |
597
- | **calendar** (EventKit) | **shipped** — `useDesktopCalendarEvents()` over `EKEventStore`, an `osascript` child under XQuartz; Evolution Data Server on Linux | — (bridge 0.8) | react-x11#504 → windowkit/appkit#39, #40 (done); writes are #41 |
598
- | **eyedropper** (`useEyedropper`) | **shipped** — `pickScreenColor()` over `NSColorSampler`, the ladder's top rung (`docs/eyedropper.md`); the system draws the loupe out of process, so no Screen Recording grant of our own. A bridge before 0.9 has no rung at all — `supported` is false, and the X11 rung needs a pointer grab and a root read the cocoa `X` stub has neither of | — (bridge 0.9) | react-x11#517 → windowkit/appkit#46 (done) |
599
- | **idle time** (`useIdle`) | **shipped** — `app.lastInputMs()`, polled on the adaptive wait the X rung uses: the bridge's `lastInputMs()` where it has one, otherwise `ioreg`'s `HIDIdleTime`, a short-lived child per check, only while a `useIdle()` is mounted | `lastInputMs()`, a CoreGraphics read | react-x11#938 → windowkit/appkit (to file) |
600
-
601
- Two of these have a **freedesktop counterpart worth having in core with no
602
- bridge at all**, because they are D-Bus like the portal and the global menu
603
- already are: the Dock badge (`com.canonical.Unity.LauncherEntry`, one signal
604
- under the app's `.desktop` id) and the permission prompts (the per-device
605
- portals — `org.freedesktop.portal.Camera`, `.Location`). Both are tracked on
606
- the react-x11 tickets above so the cross-backend API is designed once.
607
-
608
- The pattern for every rung here is `docs/filedialog.md`'s ladder: the desktop's
609
- own mechanism where there is one, a crude shell-out beneath it, and a typed
610
- "cannot do this here" at the floor — never a silent no-op.
611
-
612
- ## Text: the engine contract
613
-
614
- Everything the renderer needs from text goes through one object today —
615
- `app.fonts.layout(spans, base, options)` returning a layout with
616
- `{width, height, draw(ctx, x, y), indexAt(x, y), caretPosition(index),
617
- rangeBands(...)}` plus font loading (`openFont`/`loadFont`) and glyph
618
- queries. That interface _is_ the TextEngine contract; the macOS backend
619
- implements it over CoreText, the optional `coverage()` (#673) included:
620
-
621
- - **Matching**: family list + weight/style/`fontVariationSettings` →
622
- `CTFontDescriptor`/`CTFontCreateWithName`, with the CSS generic
623
- families mapped to system faces (`sans-serif` → SF via
624
- `systemFontOfSize`, which also finally answers the fontconfig-on-macOS
625
- wrong-font issue #86 for this backend — the right fix on the native
626
- path is not having fontconfig in it at all). `opsz` behaves (SF's
627
- optical sizing is CoreText-native). `-apple-system` and
628
- `BlinkMacSystemFont` — the system font's names in WebKit and Gecko, and
629
- in Blink, from before CSS called it `system-ui` — are `system-ui` here,
630
- in any case, so a stack written for the web that leads with them is set
631
- in SF rather than in the first fallback that happens to be installed.
632
- Only on this backend: they name the Mac's face and nothing on X11.
633
- - **Sizes reach CoreText as points.** A size the engine is handed is in
634
- device pixels, and CoreText reads some of a face's data by point size:
635
- San Francisco's optical size and tracking, and the tracking Apple Color
636
- Emoji's `trak` table adds below 29pt. Made at the device size, a font on
637
- a 2x display was the face as it is set at twice its size — a 13px label
638
- 8% narrower than AppKit sets it, a 19px emoji 1em wide where it is 23pt
639
- at 19pt — while faces with no such data (Helvetica, Menlo, most web
640
- fonts) measured the same, which is why it hid. So every font is made at
641
- `size / app.scale` points under a matrix of `app.scale` (the bridge's
642
- `scale`, windowkit/appkit#106), and CoreText answers in device pixels as
643
- before. An emoji's advance is the one number that is not exactly twice
644
- its 1x one: CoreText rounds a bitmap glyph's to a whole pixel of the font
645
- it is set in, half a point at 2x (test/cocoa-text-scale.test.js). A
646
- bridge older than the option ignores it.
647
- - **Shaping/wrapping/bidi/truncation**: `CTFramesetter` over an
648
- attributed string built from the same span list (spans map to
649
- attribute ranges — the nested-`<text>` model transfers directly).
650
- OpenType features are a property of the font, so a span with
651
- `fontVariantNumeric` or `fontFeatureSettings` is set in a font with them
652
- applied (`fontApplyFeatures`, one per face and feature set), and
653
- `letterSpacing` is the span's `kCTKernAttributeName`, with the optional
654
- ligatures turned off as ntk turns them off (issue #588). A bridge older
655
- than the verb draws the face unchanged and says so once in development.
656
- - **Metrics for yoga**: the same framesetter answers `measureContent`,
657
- so measure and render cannot disagree. Whole-pixel answers, per the
658
- yoga content-floor rules. **A width offer of zero is the min-content
659
- question**, not a degenerate layout — it is how yoga measures the floor
660
- under `minWidth: 'auto'` — and CoreText cannot be asked it directly: its
661
- breaker makes progress whatever width it is given, so a paragraph offered
662
- zero (or a pixel) comes back broken _inside_ its words. So the engine
663
- breaks the text itself, at the UAX#14 opportunities of the same
664
- `linebreak` package ntk wraps with, and has CoreText shape and measure the
665
- result — one unbreakable run per line, whose widest is the longest word.
666
- Both backends therefore agree on where a line may break and on what a
667
- `<text>` in a flex item is allowed to shrink to
668
- (`brokenAtEveryOpportunity`, src/cocoa/fonts.js;
669
- test/cocoa-text-floor.test.js). **A paragraph that elides is asked a
670
- different question**, and the engine answers it here too: it has somewhere
671
- to put the text it cannot show, so its floor is the `…` it would end in
672
- rather than its longest word. Eliding needs a width, which the zero offer
673
- is not, so the cap and the cut are made in the text — the first
674
- `maxLines - 1` broken lines and then the mark, in the face of the run the
675
- cut fell in — and CoreText is asked for neither
676
- (`cappedToEllipsis`). Left to elide unbounded it keeps the line count and
677
- puts everything over it back on the last line, which floored every
678
- `textOverflow: 'ellipsis'` label at max-content and made it overflow
679
- sideways rather than give way.
680
- - **Carets/hit/selection**: `CTLineGetStringIndexForPosition`,
681
- `CTLineGetOffsetForStringIndex`, line origins → `indexAt`,
682
- `caretPosition`, `rangeBands` — the `<textinput>`/selection surface,
683
- method-for-method. **An empty paragraph is one empty line**, as tall as a
684
- line of the face it would have been set in and aligned as one, because
685
- that is ntk's answer and every caret is measured from it. CoreText sets
686
- no line for no text, so the engine makes that one itself
687
- (`blankLine`, test/cocoa-empty-paragraph.test.js). Without it an empty
688
- field's caret was its mark padding alone, and an empty `<textarea>`'s was
689
- nothing.
690
- - **Raster**: draw the frame into a bitmap for the node's layer
691
- (`contentsScale`-aware); glyph-run-level drawing
692
- (`CTFontDrawGlyphs`) backs the `drawGlyphs`/`positioned` context
693
- extensions the terminal and code-editor components use.
694
- - **Coverage**: `coverage({ pad })`, a layout's coverage without a surface,
695
- is the bridge's `layoutCoverage` (`@windowkit/appkit` 0.13.0,
696
- windowkit/appkit#73). Each glyph's CoreText outline
697
- (`CTFontCreatePathForGlyph`) goes where the run put it, unrounded, and the
698
- layout is filled once, non-zero, by the signed-area accumulation ntk
699
- rasterizes with, so this engine and X11's give the same bytes for the same
700
- outlines. It is deliberately not CoreText's glyph rasterizer. With font
701
- smoothing off that snaps nothing, but it shrinks a small glyph's counters:
702
- Menlo's `b` at 14px comes out 5% heavier than its outline. CoreGraphics'
703
- path fill is exact to a percent but costs 160–340 µs a label, several times
704
- the draw and readback it replaces; the accumulation costs 23–58 µs. A
705
- span's colour plays no part, and Apple Color Emoji's bitmaps come out as
706
- their silhouettes. A bridge without the verb answers null.
707
-
708
- The glyph-run seams are part of that contract on both backends (issue
709
- #432, over @windowkit/appkit 0.3's glyph natives — windowkit/appkit#1).
710
- The face `fonts.match()` returns answers ntk's `glyphIdFor(cp)` (`null`
711
- when unmapped), `advanceOf(id, size)` and `shape(text, size)` beside
712
- `metrics(size)` — which reports ntk's `lineGap`/`lineHeight` alongside
713
- CoreText's `leading` — and the manager answers `fallbackFor(cp, family,
714
- opts)` off the app's loaded faces and then CoreText's cascade. The
715
- context answers `drawGlyphs(op, src, positioned)`,
716
- `createSolidPicture(r, g, b, a)` and `Render.PictOp.{Over, Src}` with
717
- ntk's exact run contract, grouping the runs by face and size into one
718
- `CTFontDrawGlyphs` per colour, so a renderer written against ntk's
719
- context — `<Terminal backend="vt">` — runs unchanged. Three things
720
- differ from ntk and are stated where they live: `shape()` runs the text
721
- through the typesetter (`fontShapeText`), and a glyph from a run
722
- CoreText substituted carries that face as `font`, which `drawGlyphs`
723
- honours — so an emoji cluster in Menlo comes out as the emoji, where ntk
724
- shapes `.notdef`; every op draws as Over, which for the opaque inks text
725
- uses is what Src does too; and the transform scales glyphs as well as
726
- their origins, because CoreGraphics draws text through the CTM like
727
- everything else.
728
-
729
- The alternative — reusing ntk's fontkit shaping stack on macOS and
730
- rasterizing through the Wayland Tier-A span compositor — is recorded,
731
- not chosen: it buys byte-identical metrics across backends (one test
732
- suite, one wrap behavior) at the cost of client-side font matching
733
- against the macOS font system (the exact pain #86 documents), no
734
- system-font niceties, and a hard dependency on the Wayland renderer
735
- workstream. The seam keeps it swappable if cross-backend metric parity
736
- turns out to matter more than platform-correct text; the default is the
737
- platform's own text system, matching how every other ladder in this
738
- project prefers the platform's own answer.
739
-
740
- Grayscale antialiasing (macOS dropped subpixel AA in Mojave) means
741
- self-rastered text is not visibly second-class — the field's CPU-raster
742
- toolkits look native on modern macOS.
743
-
744
- **IME** is the reason to be on this text stack eventually:
745
- `NSTextInputClient` on the hosting view, routed to the focused node's
746
- composition events (`CompositionStart/Update/End` already exist in the
747
- event vocabulary), gives dead keys, marked text and CJK input the way
748
- the platform means it — the capability X11 issue #272 wants and cannot
749
- yet have. Phase 5, but the view is written with the protocol stubs from
750
- day one.
115
+ Most of these are [filedialog.md](filedialog.md)'s ladder: the platform's
116
+ mechanism, a shell-out beneath it, and a typed "cannot do this here" at the
117
+ floor.
118
+
119
+ - **File open/save**: `NSOpenPanel`/`NSSavePanel`, a sheet on the window that
120
+ asked ([filedialog.md](filedialog.md)).
121
+ - **Raise**: `activateWindow()` activates the app and orders the window front.
122
+ - **Deep links, documents, reopen**: Apple Events into
123
+ `useAppOpen`/`useAppActivate`; Quit is the primary window's close request;
124
+ single instance is the platform default
125
+ ([uri-schemes.md](uri-schemes.md#on-macos)).
126
+ - **Drag and drop with other apps**: AppKit's dragging behind the same
127
+ `dropAccept`/`onDrag*`/`dragData` props
128
+ ([drag-and-drop.md](drag-and-drop.md#interoperating-with-other-applications)).
129
+ - **Clipboard**: `NSPasteboard.general` as `CLIPBOARD`; `watch()` polls.
130
+ No `PRIMARY`: selecting copies nothing ([clipboard.md](clipboard.md)).
131
+ - **Tray**: `useTray()` over `NSStatusItem`, icon names as SF Symbols
132
+ ([desktop.md](desktop.md#the-tray)).
133
+ - **Dock**: `setBadge`/`useBadge`, the `demands_attention` bounce,
134
+ `useLauncherMenu`; the name and activation policy are the `cocoa` options
135
+ ([desktop.md](desktop.md#the-launcher-a-badge-attention-and-the-dock-menu)).
136
+ - **Permissions**: `usePermission()`/`requestPermission()` over TCC, and
137
+ `openPrivacySettings`
138
+ ([permissions.md](permissions.md#what-each-kind-actually-does-on-macos)).
139
+ - **Notifications**: `notify()` over `UNUserNotificationCenter`, only in a
140
+ **code-signed app bundle**; `osascript` banners otherwise
141
+ ([notifications.md](notifications.md)).
142
+ - **Calendar**: `useDesktopCalendarEvents()` over EventKit
143
+ ([desktop-calendar.md](desktop-calendar.md)).
144
+ - **Eyedropper**: `pickScreenColor()` over `NSColorSampler`; the system draws
145
+ the loupe, so no Screen Recording grant is needed. A bridge older than 0.9
146
+ has no rung, and `useEyedropper().supported` is false
147
+ ([eyedropper.md](eyedropper.md)).
148
+ - **Idle time**: `useIdle()`, from the bridge or `ioreg`
149
+ ([system.md](system.md#useidle-and-usekeepawake--is-anyone-there)).
150
+ - **Appearance**: dark or light, the accent, and the desktop's whole palette
151
+ ([appearance.md](appearance.md#the-desktops-palette)).
152
+ - **Keep-awake**: no macOS rung — `useKeepAwake()` holds nothing here.
153
+ - **Accessibility**: none yet ([below](#not-supported-yet)).
154
+
155
+ ## Text
156
+
157
+ Text goes through CoreText behind the same API: the same `<text>` measures,
158
+ wraps, elides, selects and hit-tests on both backends, with the platform's
159
+ metrics, which differ from X11's. Layout can wrap differently across
160
+ backends; compare structure and behaviour across them, not pixels.
161
+
162
+ - **Fonts are the system's.** The CSS generic families map to system faces
163
+ (`sans-serif` is San Francisco), `opsz` is CoreText's own optical sizing,
164
+ and `-apple-system` and `BlinkMacSystemFont` read as `system-ui`, so a font
165
+ stack written for the web sets in SF. Labels and emoji measure as AppKit
166
+ sets them.
167
+ - **OpenType features** (`fontVariantNumeric`, `fontFeatureSettings`) and
168
+ `letterSpacing` work; `letterSpacing` turns optional ligatures off, as on
169
+ X11. A bridge too old for one draws the plain face and says so once in
170
+ development.
171
+ - **Lines break** at the same UAX#14 opportunities as on X11, so a `<text>`
172
+ in a flex item shrinks to the same min-content width; one that elides
173
+ (`textOverflow: 'ellipsis'`) floors at its `…`. An empty paragraph is one
174
+ empty line, so an empty field's caret has a line's height.
175
+ - **Antialiasing is greyscale**, as macOS has drawn it since Mojave.
176
+ - **Glyph-level drawing** (`drawGlyphs`, `positioned`, a face's
177
+ `glyphIdFor`/`advanceOf`/`shape`/`metrics`, `fallbackFor`) answers ntk's
178
+ contract, so a renderer written against ntk's context runs unchanged.
179
+ Three differences: `shape()` substitutes fallback faces, so an emoji in
180
+ Menlo comes out as the emoji where ntk shapes `.notdef`; every op draws as
181
+ `Over`; and a transform scales glyphs as well as their origins.
182
+ - **`coverage()`** is the signed-area fill X11 uses, so both give the same
183
+ bytes for the same outlines; colour emoji come out as silhouettes. A bridge
184
+ without the verb answers `null`.
185
+ - **No input method yet**: dead keys, Press-and-Hold accents and CJK input
186
+ wait on `NSTextInputClient` ([events.md](events.md#input-methods) says what
187
+ a dead key types meanwhile).
751
188
 
752
189
  ## Input and the run loop
753
190
 
754
- Event mapping is mechanical — the normalized shapes already exist and the
755
- dispatcher never sees platform structs:
756
-
757
- - Mouse/wheel: NSEvent → `{x, y}` flipped to top-left (POC does this),
758
- buttons, click counts (`clickCount` → `detail`), `scrollingDeltaX/Y`
759
- with `hasPreciseScrollingDeltas` → the `smooth` wheel flag (macOS is
760
- the smooth-scroll platform; the wheel pipeline already handles pixel
761
- deltas), enter/exit via a tracking area, per-window routing by
762
- `windowNumber`.
763
- - Hover is the active window's. The tracking area is `ActiveAlways`, so
764
- motion over a background window arrives, and the event manager drops it
765
- there (`app.hoverNeedsActiveWindow`, [events.md](events.md#when-the-response-is-painted)):
766
- the platform's convention, which the window server already kept for the
767
- cursor — one set by an application that is not the active one is ignored
768
- — so a link in a background window lit up under an arrow. A press still
769
- acts on the first click (`acceptsFirstMouse`), and the wheel still
770
- scrolls.
771
- - Modifiers: `modifierFlags` → `shiftKey/ctrlKey/altKey/metaKey`
772
- (⌘ = meta, ⌥ = alt — the DOM's own macOS convention, which the event
773
- API borrowed already). The app says ⌘ is what shortcuts are pressed
774
- with (`primaryModifier: 'Super'`), so the text controls answer ⌘Z/⌘C/⌘V
775
- and their edit menu prints `⌘C`
776
- ([events.md](events.md#primary-modifier)).
777
- - Keys: `keyCode` (kVK) + `characters`/`charactersIgnoringModifiers` →
778
- `keysym` (rule above), `codepoint`, `key`, `repeat`. The Latin-chord
779
- rule from `keyboard.js` (shortcuts keep working under a Cyrillic
780
- layout) is reimplemented from
781
- `charactersByApplyingModifiers:`/the kVK position map — same behavior,
782
- simpler source.
783
-
784
- **The run loop is the one architectural risk in the whole plan**, so it
785
- gets the full treatment. The POC's model — Node's loop is master, a
786
- timer pumps `nextEventMatchingMask:` — works until AppKit runs one of
787
- its internal modal loops (live window resize/drag, menu tracking,
788
- drag sessions, panel runModal), which happen _inside_ the pump call: JS
789
- is then frozen for the duration, timers queue, React cannot commit, and
790
- only render-server animations keep moving. Three options, in ascending
791
- order of invasiveness:
792
-
793
- 1. **Timer pump only** (POC today). Acceptable for the spike; input
794
- latency is the timer cadence; modal loops starve the app. Not
795
- shippable as the default.
796
- 2. **Timer pump + a libuv drain inside AppKit's loops** — install a
797
- `CFRunLoopObserver`/source that runs
798
- `uv_run(uv_default_loop(), UV_RUN_NOWAIT)` (plus microtask
799
- checkpoint) each cycle of _whatever_ run loop is spinning. AppKit's
800
- modal loops then keep Node alive: React commits during live resize,
801
- menu items update while a menu is open, timers fire. This is the
802
- standard libuv embedding recipe pointed the other way, and it is
803
- bounded addon work. The risk to design for is re-entrancy — JS
804
- running from inside a native frame that a JS call started — which the
805
- addon guards with an in-pump flag (no nested drains) and by keeping
806
- drains off the pump's own cycle.
807
- 3. **Full inversion** — `[NSApp run]` owns the process, libuv embedded
808
- as a slave (the Electron shape: watch `uv_backend_fd` on a helper
809
- thread, wake the main loop, drain there). Most correct, most
810
- invasive; not needed until proven needed.
811
-
812
- **Decision: build 2, keep 1 as the fallback switch, hold 3 in reserve.**
813
- Exit criterion for Phase 0: during a live window resize, a React
814
- state-driven relayout visibly tracks the drag — that single demo proves
815
- the drain works where it matters most.
816
-
817
- **What happened instead.** Option 2 was never built: doing a frame's work
818
- inside the delegate callbacks (`CocoaApp._afterInput`) met the exit
819
- criterion without it, and option 1 is what shipped — now the fallback
820
- (`REACT_X11_THREADED=0`) behind the default below. When the drain was finally
821
- probed (2026-09-11) its re-entrancy risk turned out to be concrete rather
822
- than theoretical — a `uv_run` nested under a JS call runs timers and never
823
- drains the microtasks they queue — and the shape that removes both of option
824
- 1's costs on stock Node and Bun is a fourth one: AppKit keeps the main thread
825
- in a real `[NSApp run]` and the JS moves to a worker. §"JS on a worker: a UI
826
- thread of the bridge's own" below.
827
-
828
- The frame clock rides the display link: `requestAnimationFrame` callbacks fire
829
- on link ticks, commits wrap in explicit `CATransaction`s, and the "answer the
830
- input" early-flush (`flushPendingFrames` on handler unwind) maps to committing
831
- the transaction before the pump returns to AppKit — same policy, new
832
- mechanism. What runs in pump mode is the timer pump with a frame interval over
833
- it (threaded mode, the default, has no pump and gives every frame a timer of
834
- its own — §"The renderer's half") (`frameInterval`; by default the period of
835
- the display each window is on, as `listScreens` reports it — 8.3ms on a 120Hz
836
- panel, 16.7 on a 60Hz monitor — with a frame that falls between two pump ticks
837
- given a one-shot timer of its own, so the period is the display's whatever the
838
- pump's cadence — and, since the frame pacer, a frame asked for between two
839
- ticks armed on the spot rather than looked at by the next tick), discrete
840
- input and the wheel flushed on the event, and a window that is not on glass
841
- deferring its frames — see "Measured" below for what each of those was worth.
842
-
843
- What the clock cannot know is whether a frame is worth painting. It has no
844
- fence: the X11 frame waits for the server to consume the last one, and a
845
- window fed off an input path — a streaming terminal — self-paces to what
846
- the server can composite; here the "server" is CoreGraphics on the JS
847
- thread and the frame is due whenever the display is, so the same window
848
- paints every refresh and the thread paints screens nobody reads. That is
849
- `frameRate`'s job ([elements.md](elements.md#framerate--pacing-the-frames-under-a-flood),
850
- [architecture/frame-pacing.md](architecture/frame-pacing.md)): a window
851
- under `'adaptive'` holds a claim while its recent frames have spent more
852
- than their share of the thread, priced by what the flush _and the present_
853
- cost — `CocoaWindow.present` reports the flip back to the node, and the
854
- damage-sized catch-up copy behind it is paid inside the next frame's flush
855
- (§"Measured: the swapchain and the WindowServer's hold"). Off by default;
856
- `cocoa: { frameInterval }` stays the clock's own cap over everything on it.
191
+ - **Pointer**: top-left coordinates, `clickCount` as `detail`, and a
192
+ trackpad's precise deltas as the wheel's `smooth` flag.
193
+ - **Hover belongs to the active window**: a background window gets no
194
+ `:hover`, enter, leave or motion until it is active, the platform's
195
+ convention ([events.md](events.md#when-the-response-is-painted)). A first
196
+ click on a background window still acts, and the wheel still scrolls it.
197
+ - **Modifiers**: ⌘ is `metaKey`, ⌥ is `altKey`. ⌘ is the app's primary
198
+ modifier (`app.primaryModifier` is `'Super'`), so the text controls answer
199
+ ⌘Z/⌘C/⌘V, their edit menu prints ⌘C, and a chord written with `Control`
200
+ means ⌘ ([events.md](events.md#primary-modifier)).
201
+ - **Keys** arrive as the same `XK_*` keysyms as on X11, and shortcuts keep
202
+ their Latin letters under a non-Latin layout. The lock state is readable
203
+ before the first key.
204
+ - **Frames** are paced per window on its own display. A window that is
205
+ ordered out, miniaturized or entirely covered by another app's window
206
+ paints nothing — its frames and `requestAnimationFrame` callbacks wait —
207
+ and gets one catch-up frame when it is back. A window fed off a stream (a
208
+ terminal under `cat`) paints every refresh unless it sets
209
+ `frameRate: 'adaptive'`
210
+ ([elements.md](elements.md#framerate--pacing-the-frames-under-a-flood)).
857
211
 
858
212
  ## JS on a worker: a UI thread of the bridge's own
859
213
 
860
- The run loop above leaves two costs standing, and the Windows PRD names
861
- them: input waits for the pump, and a modal loop freezes Node
862
- ([windows.md](windows.md#what-the-cocoa-backend-does-and-what-it-costs)).
863
- Its answer on Windows is a UI thread of the addon's own — one thread creates
864
- every window and runs every modal loop, JS stays on Node's thread, and events
865
- cross through a `napi_threadsafe_function`
866
- ([windows.md](windows.md#three-shapes-on-windows)). Win32 allows that because
867
- a window belongs to the thread that created it. AppKit does not: an
868
- `NSWindow` made anywhere but the main thread is an exception that ends the
869
- process — `createWindow2` called from a worker aborts with _"NSWindow should
870
- only be instantiated on the main thread!"_
871
-
872
- So on macOS the same shape runs with the roles swapped. **The UI thread is
873
- the process main thread, parked in a real `[NSApp run]` for the life of the
874
- app, and the renderer — React, layout, painting, CoreText — moves to a
875
- `worker_threads` Worker.** The rest of the Windows design carries over
876
- unchanged: the three rules that keep two threads from deadlocking
877
- ([windows.md](windows.md#the-rules-that-keep-it-from-deadlocking)), one
878
- wake per frame for commands, events in batches, and the bounded resize
879
- handshake ([windows.md](windows.md#resize-where-the-threads-meet)). The
880
- bridge's half is filed as
881
- [windowkit/appkit#49](https://github.com/windowkit/appkit/issues/49),
882
- with the probe's code.
883
-
884
- ### Why the JS has to leave the main thread
885
-
886
- Options 2 and 3 above keep the JS where it is and run libuv from inside
887
- AppKit's loops, and both nest: the addon is only ever entered from JS, so a
888
- `uv_run` it starts from inside `[NSApp run]` runs under the JS call that got
889
- it there. The probe below pumped the main thread's own loop that way, from a
890
- common-modes timer: the main thread's `setInterval` callbacks fired, three of
891
- three, and **none of the microtasks or next-ticks they queued ever ran**.
892
- Node drains them when the outermost callback scope closes, and a scope opened
893
- under a JS call is never the outermost — the failure
894
- [#484](https://github.com/sidorares/react-x11/pull/484) found inside the
895
- pump, now for every callback. An inversion that does not nest needs whoever
896
- owns `main()` to own the outer loop, which is why Electron patches libuv and
897
- NodeGui ships a forked Node; and Bun has no libuv loop to embed at all. On
898
- stock `node` and `bun`, a worker is the shape that frees the JS.
899
-
900
- A worker wakes on the same kind of threadsafe call, and its deliveries are
901
- outermost: a microtask queued inside one ran 0.01–0.02 ms later (p50), so a
902
- handler's `setState` commits on the event that caused it.
903
-
904
- ### Measured: the probe
905
-
906
- 2026-09-11, Apple M1 Pro, macOS 15.2, Node 26.0.0 and Bun 1.4.0, react-x11
907
- at e372289. The probe is an addon of its own, about 600 lines, in the shape
908
- proposed here: a command queue drained by a version-0 `CFRunLoopSource` in
909
- `kCFRunLoopCommonModes`, events out through one threadsafe call per wake
910
- with motion folded, a layer-hosting window like the bridge's, and the
911
- bounded resize wait. Its worker ran a 5 ms timer and posted a layer change
912
- every 16 ms through every phase.
913
-
914
- | | today: the 8 ms pump | the probe: JS on a worker |
915
- | --------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------ |
916
- | an event reaching JS | waits for the next tick, 0–8 ms | 0.02 ms p50; p95 0.13 ms idle, 1.3–1.8 ms inside a modal loop |
917
- | JS through menu tracking | frozen — a drag held a 25 ms timer for 31 s | the 5 ms timer's worst gap: 14.0 ms |
918
- | JS through an `NSAlert`'s `runModal` | frozen | 9.7 ms |
919
- | JS through a live resize | only the delegate callbacks run | 12.5 ms |
920
- | a layer change posted by JS | inline | applied 0.02 ms later, p50, whatever mode the run loop is in |
921
- | a frame at the new size, during a live resize | same thread, same call | 39 of 39 ticks inside a 50 ms wait; waited 3.1 ms p50 for 3 ms of layout |
922
- | the main thread busy for 300 ms | JS frozen | JS timers unaffected, worst gap 6.1 ms |
923
- | JS busy for 200 ms | every window frozen | windows live; the 10 events JS missed arrive as one batch |
924
-
925
- Layer changes landed inside the menu's `NSEventTrackingRunLoopMode` (72 of
926
- them) and the alert's `NSModalPanelRunLoopMode` (75), which is what a
927
- source in the common modes buys. The shipped bridge, loaded in a worker,
928
- answered the rest of the design's questions: all 182 verbs load, CoreGraphics
929
- drawing with readback and CoreText work from the worker, `createWindow2`
930
- aborts, and `measureControl` with `drawControlIntoSurface` draws correct
931
- pixels and then segfaults the process at exit — bezels stay on the UI
932
- thread.
933
-
934
- What the probe did not show: a hardware live resize (it posts mouse events
935
- into the same tracking loop), a real drag session (a real `beginDrag` holds
936
- the pointer for about 30 seconds), or anything on glass, which this
937
- machine's session cannot capture.
938
-
939
- ### Who owns what
940
-
941
- | thread | owns | reaches the other by |
942
- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
943
- | the main thread — UI | `NSApplication` and its delegate; every `NSWindow`, view and panel and their delegates; the menu bar, the Dock, status items; the cursor; the pasteboard; drag sessions, file panels, the colour sampler; control bezels; the `CATransaction` that applies each frame's layer changes; the resize wait | batched events through one threadsafe function per environment; state it publishes under a lock |
944
- | the worker — the renderer | React, layout, the node tree; CoreGraphics into IOSurface-backed surfaces; CoreText and fonts; `<glarea>`'s CGL context | commands queued to the UI thread, one wake per frame |
945
-
946
- Pixels stay on the worker, and they are most of a frame. What crosses is
947
- the frame's layer changes, as one batch the UI thread applies in one
948
- transaction — one commit, so the frame and a resized window's geometry
949
- can land together, which a commit of the worker's own could not promise.
950
- EventKit, notifications, permissions and the colour sampler already answer
951
- through threadsafe functions from queues of their own and follow whichever
952
- environment calls them.
953
-
954
- ### The bridge's half
955
-
956
- [windowkit/appkit#49](https://github.com/windowkit/appkit/issues/49)
957
- is the proposal, in five parts:
958
-
959
- - **The threading core**
960
- ([#50](https://github.com/windowkit/appkit/issues/50)):
961
- `runMain()`, the command queue, events built as plain records on the UI
962
- thread and made into JS values on the worker, published state, exit
963
- through `runMain`'s return value, signals forwarded. Pump mode stays, and
964
- one verb body serves both: parsed on the calling thread, run inline on the
965
- main thread and queued from any other.
966
- - **The AppKit verbs**
967
- ([#51](https://github.com/windowkit/appkit/issues/51)):
968
- of 182, the surfaces, drawing, layouts and fonts stay on the calling
969
- thread untouched; the rest become commands, handles allocated at the call,
970
- published reads, or answers that arrive later.
971
- - **Frames across the boundary**
972
- ([#52](https://github.com/windowkit/appkit/issues/52)):
973
- one batch of layer changes per frame, and IOSurface buffers that change
974
- hands by event, because the flip now happens when the UI thread applies
975
- the batch rather than when JS asks for it.
976
- - **The live-resize handshake**
977
- ([#53](https://github.com/windowkit/appkit/issues/53)).
978
- - **Control bezels on the UI thread**
979
- ([#54](https://github.com/windowkit/appkit/issues/54)),
980
- answered asynchronously with `BezelStore`'s cache in front.
981
-
982
- The bridge is closer to this than its synchronous API suggests. Everything
983
- AppKit asks it synchronously is already answered from state the renderer set
984
- earlier — the drop response, the drag source's masks, the Dock menu,
985
- `windowShouldClose:` (always no, with a close request to JS) and
986
- `applicationShouldTerminate:` (always cancel, with a quit request) — which
987
- is rule two of the Windows design, followed before it was written down.
988
-
989
- ### The renderer's half
990
-
991
- Threaded mode is the default on macOS: `node app.js` and `bun app.jsx` run
992
- the app on a worker, with nothing in the app or on the command line asking
993
- for it, and so does `tsx app.jsx` on Node 24 and later. On Node 20 and 22
994
- an app started under an ES module loader, tsx or any other `--import` or
995
- `--loader`, keeps the main thread and the pump (below). It needs a bridge
996
- with `runMain()`, @windowkit/appkit 0.10. What it costs at startup, how to
997
- skip it, and what moves on which Node, is in
214
+ AppKit owns the process main thread and runs its menus, drags, live resizes
215
+ and modal panels there. So on macOS react-x11 **moves the app's JS onto a
216
+ `worker_threads` Worker** by default, and the main thread sits in a real
217
+ `[NSApp run]`: the JS keeps running through every menu and resize, and input
218
+ reaches it as it happens rather than at the next pump. It needs a bridge with
219
+ `runMain()` (`@windowkit/appkit` 0.10).
220
+
221
+ `node app.js` and `bun app.jsx` move with nothing asked for, and so does
222
+ `tsx app.jsx` on Node 24 and later. The launcher moves the app before its
223
+ entry is loaded at all, which is cheaper:
224
+
225
+ ```sh
226
+ node --import react-x11/cocoa-main app.js
227
+ bun --preload react-x11/cocoa-main app.jsx
228
+ ```
229
+
230
+ The app **stays on the main thread**, with the pump, off macOS; with
231
+ `REACT_X11_THREADED=0` or `REACT_X11_BACKEND=x11`; in a REPL or under `-e`;
232
+ in a single executable (a Node SEA, `bun build --compile`); in a `<Frame>`
233
+ pane; under a test runner; when react-x11 is imported after the app started
234
+ running (an `import()` the entry awaits, or one from a timer); and on Node 20
235
+ and 22 under an ES module loader (`--import`, `--loader`, tsx). There a menu,
236
+ a drag, a live resize or a modal panel stops the JS while it is open. The
237
+ startup cost of each route and the matrix by Node version are in
998
238
  [packaging.md](packaging.md#on-macos-the-app-moves-onto-a-worker).
999
239
 
1000
- - **The move** (`src/cocoa/relaunch.js`, reached from `src/bootstrap.js`,
1001
- the first import of `src/index.js`). An entry's imports finish before its
1002
- body runs, so when react-x11 is evaluated on the main thread the app has
1003
- not run a line. It starts a Worker on that same entry, with the node
1004
- flags the process started with and a shared environment, and parks the
1005
- main thread in `Atomics.wait` — no AppKit yet — until the worker says
1006
- what it needs. The flags are inherited, not handed over as `execArgv`: a
1007
- Worker refuses a list holding one V8 or process-wide flag, such as
1008
- `--expose-gc`, and those hold for every thread already. The worker runs
1009
- a few lines of react-x11's own before the entry (`WORKER_START`), so
1010
- that however early it stops, it can say so. Node starts it on them, as
1011
- `eval` code that then runs the entry the way Node runs a worker's file:
1012
- `process.argv[1]` set to it, and `Module.runMain`, which runs the
1013
- `--import` loaders first and makes the entry the main module,
1014
- `require.main` and `import.meta.main` as they were. An `import()` of the
1015
- entry does neither (measured, Node 20–26). Bun's eval worker loses both
1016
- an entry's failure and `require.main`, so there the entry stays the
1017
- worker's own and the lines go in as its `preload`. The
1018
- first cocoa `createRoot` asks for AppKit (`requestAppKit`), and the main
1019
- thread launches it and enters `runMain()`; an app that never makes one,
1020
- an X11 app on XQuartz or a script, never launches AppKit and never gets a
1021
- Dock tile. It is synchronous throughout, `process.exit` being the main
1022
- thread's only way out, because a top-level await would cost the CommonJS
1023
- builds and `require()` of the package. It declines, and the app keeps the
1024
- pump, off macOS; with `REACT_X11_THREADED=0` or `REACT_X11_BACKEND=x11`;
1025
- for a REPL or `-e`; in a single executable, whose entry is not a file a
1026
- worker can load; in a `<Frame>` pane, which has no AppKit to keep and
1027
- talks to its host over the fork's IPC channel, the main thread's — a
1028
- worker has no `process.send` ([frame.md](frame.md)); under a test
1029
- runner; when react-x11 is imported after the app started running; and on
1030
- Node 20 and 22, under an ES module loader. Node tells the late import by
1031
- what evaluates it. It starts an app from two modules of its own,
1032
- `node:internal/main/run_main_module` and `node:internal/modules/run_main`:
1033
- a CommonJS entry's first run and a `--require` preload evaluate under
1034
- them, and an ES module entry's graph, an `--import` preload's too, under
1035
- an async function there, which V8 keeps on the stack as an async frame.
1036
- An `import()` is evaluated by a job of its own, whose stack ends in the
1037
- module loader, or in the app's code that awaits it. So react-x11 reads
1038
- its own stack, async frames and all, as it is evaluated (measured, Node
1039
- 20.20 to 26.0, nested and behind a top-level await, under tsx,
1040
- `--experimental-loader`, source maps and a stack trace limit of 0). The
1041
- public signal, `performance.nodeTiming.loopStart`, -1 until the event
1042
- loop turns, was the one before, and it is wrong both ways: Node 20 and 22
1043
- turn the loop while they load an ES module entry, so no ES module entry
1044
- moved there, and 24 and later evaluate an `import()` the entry makes
1045
- before the loop turns, so an app that awaited one moved and ran its entry
1046
- twice.
1047
- The loader rule is tsx's: it keeps its hooks to the main thread on Node 20
1048
- and 22, and a worker without them stops on the first `.jsx` or `.ts` file
1049
- it loads (measured, 20.20 and 22.16). No loader can be asked before the
1050
- move, so there any `--import` or `--loader`, on the command line or in
1051
- `NODE_OPTIONS`, keeps the app where it is, and the launcher moves one
1052
- whose loader follows.
1053
- Bun has neither to read: it reports no loop, and leaves no frame below a
1054
- module it evaluates. The entry's record in its module cache, which it
1055
- went by before, was wrong both ways: Bun marks a CommonJS module loaded
1056
- before its body runs, so `bun app.cjs` never moved, and an ES module has
1057
- no record until it is evaluated, so an entry that ran a line and then
1058
- awaited its `import()` of react-x11 moved and ran that line again on the
1059
- worker. It reads three things instead (measured, Bun 1.4). `bun:main`,
1060
- the module Bun writes to run the entry from, is in `require.cache` once
1061
- the entry has finished, top-level awaits and all, so an import after
1062
- that is late. Code below react-x11 on the stack loaded it synchronously,
1063
- as Bun's loader leaves no frame below an ES module's imports: a CommonJS
1064
- entry is on its first run while its own wrapper is the outermost frame,
1065
- and a CommonJS module an ES module entry imports is part of the entry's
1066
- graph. And an ES module's body runs only once every module it imports
1067
- statically has been evaluated: while one of the entry's is not in
1068
- `require.cache` — read with Bun's own scanner and resolver — react-x11 is
1069
- part of its graph, and once all are, the entry's body has started and
1070
- the `import()` is its own.
1071
- - **`react-x11/cocoa-main`** (`src/cocoa/main.js`) is the same move asked
1072
- for by name — `node --import react-x11/cocoa-main app.js`,
1073
- `bun --preload react-x11/cocoa-main app.jsx` — before the entry is loaded
1074
- at all, which spares the main thread loading the entry's imports for
1075
- nothing, and without the checks — all but one: in a `<Frame>` pane,
1076
- which gets this `--import` whenever its host was started under it, it
1077
- does nothing, as it does off macOS and inside a Worker the app starts.
1078
- - **The worker's side** (`bootstrapWorker`), set up by the same import on
1079
- the worker, which recognises the shared state in its `workerData`; the
1080
- pieces are `src/cocoa/threaded.js`'s. Stdout, stderr and the console
1081
- write straight to fds 1 and 2: a Worker's own forward through its
1082
- parent's loop, which is parked, and Bun's console bypasses
1083
- `process.stdout` altogether. The new console keeps what the runtime put
1084
- on the one it replaces and a `Console` lacks — the inspector's
1085
- `timeStamp`, `profile`, `profileEnd`, `context` and `createTask`, and
1086
- `console.Console` — because code that loaded first has already looked:
1087
- React's development reconciler checks `console.timeStamp` once, as it
1088
- loads, and calls it on every render after, so a script that imported
1089
- `react-x11/test` ahead of `react-x11` threw on its first render.
1090
- `process.exit` asks the main thread first
1091
- (`requestExit`) and then ends the worker, which is also how Node's own
1092
- handling of an uncaught error reaches it, with code 1; `exit()` on the UI
1093
- thread, the probe's first try, raced static destructors. From its first
1094
- line, before any of this (`WORKER_START`), the worker says it is done
1095
- after the app's last exit listener — kept last, since Node runs a
1096
- worker's exit listeners through `process.emit` and Bun calls them
1097
- directly (measured) — and the main thread ends the process on that, so
1098
- an app's exit handlers finish as they would in a process of its own. It
1099
- prints its own uncaught error, because the parked main thread never runs
1100
- its loop again to hear the worker's `'error'`. Both used to wait for this
1101
- set-up, and a worker that stopped before its import of react-x11 had run
1102
- there did neither: the process waited for ever, with nothing printed.
1103
- Such an error now comes with a word on why, since the main thread had
1104
- run the same code without a complaint (§"What changes for an app"). A
1105
- `signal` event is re-emitted on the worker's `process`, and one nobody
1106
- listens for exits 128 + n. The first cocoa root waits for `runMain`
1107
- before it builds the app: until the run starts the bridge has published
1108
- nothing, and an app that asked `listScreens()` then got `[]` and took
1109
- scale 1 on a 2x panel (measured). What the app imports before react-x11
1110
- runs before this set-up, so a module that logs as it loads belongs after
1111
- it.
1112
- - **One channel** (`openThreadedChannel`). The bridge takes one `connect`
1113
- per environment, so the bootstrap opens it before the entry runs and
1114
- fans the batches out — signals to itself, the rest to `CocoaApp`. What a
1115
- subscriber throws leaves the delivery once every subscriber has had the
1116
- batch, and the bridge makes it the worker's uncaught exception. Before
1117
- 0.10 it was swallowed: an exception out of a threadsafe function's
1118
- callback is, under Node's default policy, a warning — the app went on
1119
- with its window up and the error gone (measured, and fixed in the bridge
1120
- as windowkit/appkit#65).
1121
- - **`CocoaApp`** (`src/cocoa/app.js`) is fed by the batches
1122
- (`_routeBatch`): each event is routed as the pump routes it, what the
1123
- inputs owe — React's half, the paint, the present (`_afterInput`) — is
1124
- paid once per batch, and then comes the frame tick the pump did. No
1125
- interval and no `pump2`; every frame gets a timer of its own, and
1126
- `_routeGeometry`'s coalesced second flush is the pump's alone. The display link ticking from the UI thread through the same
1127
- channel, the Windows PRD's frame thread
1128
- ([windows.md](windows.md#the-frame-clock)), is still to come.
1129
- - **Windows are keyed by the handle.** `createWindow2` answers a handle at
1130
- the call; `windowNumber` and the published frame arrive with
1131
- `window-created`, and every window event names the handle.
1132
- - **The fence.** A flip is a command the UI thread applies later, so the
1133
- buffer it takes off glass is still on glass when the call returns.
1134
- `CocoaWindow.frameInFlight()` — the X11 contract's fence, which the pump
1135
- never needed, and whose `frameHeld()` half is what the frame clock asks
1136
- (`CocoaApp._tickFrames`) — holds the window's next frame until `surface-released`
1137
- names that buffer, and no frame draws into that buffer before then, one
1138
- that did not wait included. A new size is never in flight: it paints
1139
- into a new chain that nothing shows. A release that never comes lets the
1140
- frames go after 100 ms, and not the buffer. And the release is the flip
1141
- applied, not the WindowServer letting go of the buffer: that is
1142
- `surfaceIsInUse`'s to say, and a frame asks it of every buffer it might
1143
- take (§"Measured: the swapchain and the WindowServer's hold").
1144
- - **The resize handshake.** Each flip is a recorded frame committed with
1145
- the size it was painted at, in AppKit's own points, and each window asks
1146
- AppKit to wait for it for `cocoa: { resizeWait }` ms — 50 by default, 0
1147
- for none. The layer presenter's frame commits its size too. A `<Frame>`
1148
- pane is held to the same budget the other way round: while its frames of
1149
- a new size come within it, its layer shows the last one at its size,
1150
- cropped where the layer shrank and its last column and row carried over
1151
- where it grew (`contentsRect`, `contentsCenter`), rather than stretching
1152
- it, and past it, stretches it across and still not down
1153
- (docs/frame.md §"Sizing, stacking, input").
1154
- - **The screen layout is pulled.** `listScreens()` is always current — the
1155
- bridge republishes its copy on
1156
- `NSApplicationDidChangeScreenParametersNotification`, and in pump mode
1157
- asks AppKit live — but nothing tells the app, so `CocoaApp` asks:
1158
- `refreshScreens()` re-reads it, compares, and publishes a desk that moved
1159
- into `src/screens.js`. It runs before a placement reads the layout
1160
- (`availableArea`), when a window reports that it moved — at most once a
1161
- second, since a drag reports every frame — and on a clock while a
1162
- `useScreens()` subscriber is mounted, `cocoa: { screenPoll }` ms apart
1163
- (500 by default, 0 for none). An app that never watches the layout keeps
1164
- no clock. What the read does **not** re-derive is `app.scale`: every rect
1165
- this backend speaks in is points times that factor, and moving it under
1166
- the windows, the events and the surfaces that already exist in that space
1167
- is a different change from re-reading a layout.
1168
- - **A live resize is what AppKit brackets**: `liveResizing` is set between
1169
- the bridge's `window-live-resize` begin and end (windowkit/appkit#63), in
1170
- both modes. AppKit's tracking loop reports a resize per pointer move and
1171
- nothing when the drag stops, so the pump used to read the end as "a pump
1172
- tick ran", and a worker, which no AppKit loop holds, has nothing like
1173
- that to read; a drag that pauses now stays a drag, and the catch-up frame
1174
- runs on the tick after the release.
1175
- - **Answers that come later.** `clipboard.read()` and `targets()`;
1176
- `CocoaWindow.snapshot()`, a promise now on both threads; the drop's
1177
- payload, read from the event's `items`; and bezels. `BezelStore.prefetch`
1178
- asks for every kind's metrics beside the rest of startup — 41–49 ms cold,
1179
- where the yoga load beside it takes 15–50 — so layout reads them
1180
- synchronously as before. A bezel not drawn yet is null: its canvas draws
1181
- the one it had and repaints when the new one lands, and a bezel drawn at
1182
- rest brings its pressed twin, so a press never waits for one.
1183
- - **Tests.** The 52 `test/cocoa-*.test.js` files drive fake bridges that
1184
- answer synchronously and keep passing on the pump;
1185
- `test/cocoa-threaded.test.js` drives the same app over a fake in a
1186
- worker's shapes, `test/cocoa-relaunch.test.js` pins when the move
1187
- happens, under Bun too where `bun` is on PATH, and the worker's side of
1188
- the hand-off, and
1189
- `test/cocoa-threaded-launch.test.js` runs real apps with the real bridge
1190
- — moved by the import, by the launcher, kept on the pump, and one that
1191
- never makes a root (it skips without a bridge that has `runMain`).
1192
- The contract the renderer needs is the X11 one, asynchronous from the
1193
- start, which [windows.md](windows.md#the-rules-that-keep-it-from-deadlocking)
1194
- makes the same point about.
1195
-
1196
- Measured 2026-09-11, M1 Pro, Node 26.0.0 and Bun 1.4.0, the bridge built
1197
- from its main branch at 0b17653: `createRoot` took 92 ms on the worker and
1198
- 102 ms with the pump, for one window; the launch test's three cases —
1199
- the entry on a worker with its window made and painted, `process.exit(7)`,
1200
- and an uncaught error printed with exit 1 — pass under both runtimes; and
1201
- `examples/app.jsx` ran under the launcher with nothing on stderr. On the
1202
- published 0.10.0, spawn to the first painted window of a one-window app,
1203
- median of seven: Node 471 ms on the pump, 544 ms moved by the import,
1204
- 462 ms moved by the launcher; Bun 408, 446 and 385 ms; memory 170, 219 and
1205
- 186 MB under Node, 150, 182 and 159 MB under Bun. `examples/raster-gate.jsx`,
1206
- started with no flag under Bun and under `node --import tsx`, kept
1207
- animating through a live resize at 48–62 fps.
1208
-
1209
- The activation policy is the launch's: the launcher has launched AppKit
1210
- before the app's code runs, so `cocoa: { activationPolicy }` can only
1211
- switch it afterwards, when a Regular launch has already shown its Dock
1212
- tile. The bridge reads `APPKIT_ACTIVATION_POLICY` as it launches
1213
- (windowkit/appkit#67) — `APPKIT_ACTIVATION_POLICY=accessory node --import
1214
- react-x11/cocoa-main app.js` for a menu-bar app, or `LSUIElement` in a
1215
- bundle's `Info.plist` — and an app whose option disagrees with how it
1216
- launched is told so, naming the variable.
1217
-
1218
- What it does not do yet, each noted where it lives:
1219
-
1220
- - A drop's binary types, an image, are offered and read as nothing: the
1221
- event carries the text and URL forms, and a worker cannot read the drag
1222
- pasteboard inside the callback.
1223
- - The layer presenter's colour transitions start from the declared value,
1224
- since `presentationValue` answers later on a worker.
1225
- - A transparent window's shadow is recomputed a frame interval after a
1226
- flip, rather than once the flip has committed.
1227
- - `process.stdin` still forwards through the parked main thread.
1228
- - A worker that cannot load the entry stops the app, where the main thread
1229
- could have run it with the pump. Under a loader that keeps its hooks to
1230
- the main thread — tsx before 4.21.1 on Node 24 and later (measured, 4.20.6
1231
- on 24.12 and 26.0), or tsx on 20 and 22 under the launcher — the app
1232
- moves and stops as it starts, on `ERR_UNKNOWN_FILE_EXTENSION`. A load
1233
- failure has run nothing of the app's on the worker, so the main thread
1234
- could carry on in its place, if `WORKER_START` told such a failure from
1235
- the rest.
1236
- - Under Bun, react-x11 required from a callback before the entry has
1237
- finished reads as the entry's first run, and the app moves and runs that
1238
- code again on the worker: from a nextTick or a microtask a CommonJS
1239
- entry queues in its own file, or from a timer while an ES module entry
1240
- waits on a top-level await. Bun runs a CommonJS entry's nextTicks and
1241
- microtasks before it evaluates `bun:main`, and its stack does not tell a
1242
- callback's frame from a module's own. A callback that ends by returning
1243
- its `require()`, in strict code, leaves no frame at all — JavaScriptCore
1244
- makes that a tail call — and reads as late.
240
+ **A menu-bar app sets its policy at launch.** On a worker AppKit is up before
241
+ the app's code runs, so `cocoa: { activationPolicy }` can only switch it
242
+ afterwards, when a Regular launch has already shown a Dock tile. Launch with
243
+ the variable, or put `LSUIElement` in the bundle's `Info.plist`; an app whose
244
+ option disagrees with its launch is told so in development.
245
+
246
+ ```sh
247
+ APPKIT_ACTIVATION_POLICY=accessory node --import react-x11/cocoa-main app.js
248
+ ```
1245
249
 
1246
250
  ### What changes for an app
1247
251
 
1248
- - About **16 MB** of RSS for the worker's isolate (a bare `node` at 46.5 MB,
1249
- 62.5 MB with one idle worker) and **26–31 ms** before the worker runs its
1250
- first line.
1251
- - The idle app stops waking 125 times a second: the pump is an 8 ms
1252
- interval whether anything happens or not.
252
+ - About **16 MB** of RSS for the worker's isolate, and **26–31 ms** before
253
+ the worker runs its first line.
254
+ - The idle app stops waking 125 times a second: the pump is an 8 ms interval
255
+ whether anything happens or not.
1253
256
  - Inside a Worker, `process.chdir()` throws, `process.on('SIGINT')` is never
1254
- called (the launcher forwards signals), `process.env` is a copy unless it
1255
- is shared, and `process.stdin` forwards through the main thread the way
1256
- stdout does.
1257
- - What the entry imports ahead of react-x11 runs twice: on the main thread,
1258
- then again on the worker. A call only a main thread may make —
1259
- `process.chdir()`, `process.umask(mask)` — goes through the first time
1260
- and throws the second, and the app stops with that error and a word on
1261
- why. Make it on the main thread only (`isMainThread`, from
257
+ called (the launcher forwards signals: a signal is re-emitted on the
258
+ worker's `process`, and one nobody listens for exits with 128 + n),
259
+ `process.env` is a copy unless it is shared, and `process.stdin` forwards
260
+ through the main thread the way stdout does.
261
+ - **What the entry imports ahead of react-x11 runs twice**: on the main
262
+ thread, then on the worker. A module that does work as it loads — opens a
263
+ port, writes a file, logs — does it twice. A call only a main thread may
264
+ make — `process.chdir()`, `process.umask(mask)` — goes through the first
265
+ time and throws the second, and the app stops with that error and a word
266
+ on why. Make it on the main thread only (`isMainThread`, from
1262
267
  `node:worker_threads`): what it changes is the process's, so the worker
1263
268
  starts with it. One made after the import runs on the worker alone and
1264
- throws there; `REACT_X11_THREADED=0` keeps the whole app on the main
1265
- thread.
269
+ throws there. Import react-x11 first in the entry, or set
270
+ `REACT_X11_THREADED=0` to keep the whole app on the main thread.
271
+ - `process.exit()` and an uncaught error end the app as they would a process
272
+ of its own: exit handlers run, and the error is printed with exit code 1.
1266
273
  - `--inspect` attaches to the main thread; the app is the worker target
1267
274
  beside it.
1268
- - Packaging: a single-executable build has to carry the worker's entry
1269
- beside the main one ([packaging.md](packaging.md)), and
1270
- `bun build --compile` has to be given it as an entry point.
1271
-
1272
- ### Not probed yet
1273
-
1274
- - A hardware live resize, a real drag session, and pixels on glass.
1275
- - `<glarea>`'s `x11-dri` CGL context inside a worker.
1276
- - `react-x11/refresh`'s module hooks inside a worker.
1277
- - The swapchain on glass. No frame writes into a buffer the WindowServer
1278
- holds, measured at the moment of every write (§"Measured: the swapchain
1279
- and the WindowServer's hold"), and the scroll blit moves its band as the
1280
- frame takes its buffer, or in one already caught up; but a torn frame has
1281
- not been looked for on screen, before the change or after it, and a
1282
- capture cannot see one.
1283
- - IME and NSAccessibility, neither built yet: both are questions AppKit asks
1284
- the view synchronously, and both take the answer the Windows design gives
1285
- them — the caret rect and a copy of the accessibility tree, pushed ahead
1286
- to the UI thread.
1287
-
1288
- ### The order
1289
-
1290
- The bridge's core and every verb have landed (windowkit/appkit#50–#54), and
1291
- so have a batch-fed `CocoaApp` and the move that makes threaded mode the
1292
- default on macOS. Pump mode stays, unchanged, as the off switch
1293
- (`REACT_X11_THREADED=0`) and wherever the move declines — a single
1294
- executable first among them. Still to take under threaded mode: an
1295
- input-to-photon measurement and the resize cell of the Cocoa bench, which
1296
- runs its columns on the pump — perceived latency is the metric this
1297
- project grades on, and latency is what the change is for.
275
+ - A single executable does not move, and runs on the pump
276
+ ([packaging.md](packaging.md)).
277
+
278
+ Not done yet on a worker:
279
+
280
+ - A dropped image, or any binary type, reads as nothing: the drop carries its
281
+ text and URL forms.
282
+ - The layer presenter's colour transitions start from the declared value.
283
+ - A transparent window's shadow is recomputed a frame interval after a flip,
284
+ not once the flip has committed.
285
+ - A worker that cannot load the entry stops the app: under tsx before 4.21.1
286
+ on Node 24 and later, or tsx on Node 20 and 22 under the launcher, it stops
287
+ as it starts on `ERR_UNKNOWN_FILE_EXTENSION`. Update tsx, or set
288
+ `REACT_X11_THREADED=0`.
289
+ - Under Bun, react-x11 required from a callback before the entry has
290
+ finished — a `nextTick` or microtask a CommonJS entry queues, a timer while
291
+ an ES module entry awaits at top level — reads as the entry's first run,
292
+ and that code runs again on the worker.
293
+ - Not yet tried on the worker: `<glarea>`'s GL context and
294
+ `react-x11/refresh`'s module hooks.
1298
295
 
1299
296
  ## Native controls
1300
297
 
1301
- House rule applied: **default native, seam out.** On the Cocoa backend
1302
- core controls render with AppKit's own pixels; interaction, focus,
1303
- keyboard and a11y semantics stay the shared implementation (the
1304
- "answer-the-input" press model is behavior this project considers part
1305
- of its identity, and AppKit's own controls would take it over
1306
- wholesale — different focus ring timing, different keyboard rules,
1307
- different event routing — which is why real `NSView` controls embedded
1308
- in the layer tree are _not_ the mechanism).
1309
-
1310
- The mechanism is the POC's proven one: **offscreen NSCell/control
1311
- rendering into cached images** (the WebKit/Gecko technique), keyed
1312
- `(kind, size, state, appearance, accent)`, nine-slice-stretched via
1313
- `contentsCenter` where the bezel allows, set as the control node's layer
1314
- contents. Push buttons (including the accent-filled default button),
1315
- checkbox, radio, popup, slider knob/track, switch — all render today in
1316
- both appearances with pressed/on states.
1317
-
1318
- Wiring, shaped as theme policy rather than per-widget forks:
1319
-
1320
- - `ThemeProvider` grows `controls: 'auto' | 'native' | 'drawn'`. The
1321
- default, `'auto'`, takes bezels wherever the backend has them — the
1322
- Cocoa backend, and win32 while the desktop is light (`win32/bezels.js`
1323
- says why) — and the drawn controls everywhere else, X11 included.
1324
- `'native'` asks for them outright; on a backend without them it warns
1325
- once and draws, because an error would make the same app code illegal
1326
- on X11, which is the opposite of what a cross-backend widget set is
1327
- for. `'drawn'` is this library's rendering on every backend.
1328
- Per-instance escape hatch: `native={false}` on a control for the odd
1329
- custom-branded button. A drawn control on macOS keeps today's themed
1330
- rendering, so custom-designed apps lose nothing.
1331
- - Components keep their exact public contracts (`checked`/`onChange`,
1332
- the `changeEvent` shape, `useControl` states) and swap only the
1333
- _bezel_: where the drawn variant renders well/track/thumb boxes, the
1334
- native variant renders a bezel-image node sized by the cell's natural
1335
- metrics. Hover/press/focus continue to drive it — pressed bezels are
1336
- re-keyed images, and the press state still lands on the press frame.
1337
- - A native control is **two boxes**: the footprint the caller's `style`
1338
- sizes, and the control itself — bezel, title and press wash — at AppKit's
1339
- metrics, centred in it. One box could not be both, because a cell's title
1340
- is _placed_ against the bezel's bottom edge rather than centred in the
1341
- box, and a `height`, a `flexGrow` or a parent's align-stretch moved the
1342
- box without moving the bezel the system draws at one size: the label
1343
- ended up below the control it names (#510). The role, the handlers, the
1344
- focus ring and the ref stay on the control, so the slack around it is
1345
- slack — a press there does nothing, as it does in AppKit.
1346
- - A `<Button>` label **wraps** when its row is too narrow for it, as a drawn
1347
- one does, and the control grows by the lines it gained — so a push button
1348
- is AppKit's height only while its title is one line. A push bezel is one
1349
- height (asked for a taller frame, AppKit draws its own 22pt centred in
1350
- it), so a box taller than that is drawn as `NSBezelStyleFlexiblePush`:
1351
- the same button stretched to its frame, which AppKit gives a title that
1352
- wraps and WebKit and Gecko draw a tall push button with. At one line the
1353
- two are the same pixels, except the default button's accent, which is
1354
- flat in the flexible bezel where the push has a sheen. A bridge that
1355
- predates the kind keeps the push and says so once, in development
1356
- (`src/cocoa/bezels.js`, "Taller than one line"). Pinning the title to one
1357
- line's height put its first line above the bezel instead.
1358
- - The palette gains a macOS system theme whose tokens read semantic
1359
- `NSColor`s (accent, text, separators) so _drawn_ content — cards,
1360
- tables, custom widgets — sits harmoniously beside native bezels, and
1361
- the desktop accent reaches `theme.accent` on this backend through the
1362
- same explicit adoption rule as on Linux.
1363
- - Text controls stay drawn everywhere for now: `<textinput>`'s editing
1364
- model (undo, selection, caret cadence from desktop settings) is deep
1365
- shared behavior, and a native `NSTextField` would fork it. The native
1366
- win for text is IME (§Text), not the bezel.
1367
- - `NSSwitch`'s knob animation: the native bezel pair + the existing
1368
- `transition` on the thumb reproduces it; if fidelity disappoints, the
1369
- switch is the one control worth revisiting as a real view. Menus and
1370
- popover _panels_ are never bezel-rastered — their vibrancy needs
1371
- private API; they go native for real (§Menus) or stay drawn.
1372
-
1373
- `useSupports('nativeControls')` reports the capability so component code
1374
- and apps can branch the way `'shaders'` consumers already do.
298
+ `Button`, `Checkbox`, `Radio`, `Switch`, `Slider` and `Select` wear
299
+ **AppKit's own bezels** here by default; interaction, focus, keyboard and
300
+ accessibility stay the shared implementation. A bezel is an image drawn
301
+ through the ordinary paint path, not an embedded `NSView`
302
+ ([components.md](components.md#native-bezels)).
303
+
304
+ - `ThemeProvider`'s `controls`: `'auto'` (the default) takes bezels wherever
305
+ the backend has them — Cocoa, and win32 while the desktop is light — and
306
+ draws elsewhere; `'native'` asks outright, and on a backend without them
307
+ warns once and draws; `'drawn'` is this library's rendering everywhere.
308
+ `native={false}` on one control is the escape hatch.
309
+ - `useSupports('nativeControls')` says whether the backend has them.
310
+ - A native control is **two boxes**: the footprint `style` sizes, and the
311
+ control at AppKit's metrics, centred in it. Role, handlers, focus ring and
312
+ ref are on the control, so a press in the slack around it does nothing.
313
+ - A `<Button>` label **wraps** when its row is too narrow, and the control
314
+ grows with it; past one line it is AppKit's flexible push button, whose
315
+ default-button accent is flat. A bridge too old for that bezel keeps the
316
+ push and says so once in development.
317
+ - Text controls stay drawn: a native `NSTextField` would fork the shared
318
+ editing model.
319
+ - Drawn content sits beside the bezels through the desktop's palette
320
+ ([appearance.md](appearance.md#the-desktops-palette)).
1375
321
 
1376
322
  ## Menus: the global menu, finally at home
1377
323
 
1378
- **A popup on this backend is sized as an NSMenu.** Where the backend
1379
- renders native controls, `<ContextMenu>`, a `<MenuBar>`'s dropdowns and a
1380
- `<Select>`'s list take NSMenu's geometry rather than the palette's — 22pt
1381
- rows, 5pt of padding, 11pt separators, the 13pt menu font at regular
1382
- weight, a 5pt highlight radius — read off `NSMenu.size` (`NATIVE_MENU` in
1383
- `components/native.js`). The drawn menu's 30pt rows of 14px medium text
1384
- read as another toolkit's beside the menus the system's own controls open.
1385
- The menu bar itself keeps the drawn metrics: a bar is not an NSMenu.
1386
-
1387
- **A `<Select>`'s list is the NSMenu itself** where the bridge has
1388
- `popUpMenu` (@windowkit/appkit 0.20): `CocoaApp.popUpMenu` drops it from the
1389
- trigger through an `NSPopUpButtonCell` nobody draws, as a browser drops a
1390
- `<select>`'s, and a pane asks its host to (`pane-menu`). The drawn list
1391
- stays for a drawn select that does not ask for it (`nativeMenu`) and for an
1392
- older bridge.
1393
-
1394
- The Linux global menu was built for exactly this moment: the item
1395
- vocabulary is data (dbusmenu's), `MenuBar` draws the same array it
1396
- exports, and the pure `snapshot`/`diffSnapshots`/`IdAllocator` machinery
1397
- in `dbusmenu.js` produces stable ids and structural-vs-property diffs
1398
- with no D-Bus in sight. The macOS adapter consumes precisely that:
1399
-
1400
- - `snapshot(menus)` → build/patch an `NSMenu` tree; `IdAllocator` ids
1401
- key an `NSMenuItem` cache across re-renders; `diffSnapshots` decides
1402
- patch-in-place (title/enabled/state) vs. rebuild (structure).
1403
- `itemProperties()` is the one function swapped — dbusmenu props out,
1404
- `{title, enabled, hidden, state, keyEquivalent(+mask), submenu}` in.
1405
- - Item activation carries the id back; `itemFor(id).onSelect` fires —
1406
- the same contract the registrar path has.
1407
- - `useGlobalMenu` on this backend **always delegates** — the fallback
1408
- drawn bar (`if (delegated) return null` in `MenuBar`) simply never
1409
- renders, and `onGlobalMenuChange(true)` reports it. No registrar
1410
- probing, no liveness rules: the menu bar is a platform constant.
1411
- - macOS requires the standard skeleton — the application menu
1412
- (About/Preferences/Hide/Quit), Edit, Window — which the adapter
1413
- synthesizes around the app's menus by default (the main customer
1414
- ships a Mac-correct menu bar with zero configuration), with the
1415
- app-menu items reachable as props for the apps that name their own
1416
- (`about`, `preferences` handlers). `editmenu.js`'s item set feeds the
1417
- standard Edit menu so text controls get theirs.
1418
- - **Accelerators invert.** On X11 the app window matches chords itself;
1419
- in `NSMenu` the key equivalent belongs to the item. The adapter maps
1420
- chords → `keyEquivalent` + modifier mask, reading `Control` as ⌘: a
1421
- cross-platform chord is written in the _primary_ modifier, and
1422
- `[['Control','S']]` taken literally would demand ⌃S on a Mac. No
1423
- `Primary` token was needed for that — `Control` already is one — and the
1424
- window's own matching reads it the same way (`app.primaryModifier`,
1425
- `matchesShortcut(ev, shortcut, { primary })`), so `useAccelerator` and a
1426
- drawn row answer and print ⌘ too. A delegated bar's bindings are **not**
1427
- disarmed wholesale, as first planned: the menu bar answers only the
1428
- chords it can spell, and F5, `plus` or a second alternative would have
1429
- gone dead. Instead `useMenuAccelerators` _reserves_ exactly the ones it
1430
- does answer (`answersShortcut`, cocoa/globalmenu.js), so no binding in
1431
- the window takes them — the bridge shows JS every key before
1432
- `[NSApp sendEvent:]`, and an item answered by both would run twice
1433
- ([events.md](events.md#the-macos-menu-bar)).
1434
- - `ContextMenu` defaults to a native `NSMenu` popup on this backend
1435
- (same adapter, `popUpMenuPositioningItem:`), with the drawn popup as
1436
- the seam (`native={false}`) — the drawn one exists, works, and some
1437
- apps will want visual consistency with custom themes. Note the native
1438
- menu runs a tracking loop: run-loop option 2 is what keeps React
1439
- alive while it is open (menu-item enablement updating live is the
1440
- test).
324
+ **The menu bar is the system's, always.** A window's `menus` go to the macOS
325
+ menu bar as soon as they are installed, the drawn bar never renders, and
326
+ `onGlobalMenuChange(true)` reports it ([globalmenu.md](globalmenu.md)). Item
327
+ icon names are SF Symbols.
328
+
329
+ - An **application menu** goes in front of the app's, carrying Quit (⌘Q).
330
+ Quit is a quit request: the primary window's close request, which
331
+ `onCloseRequest` can intercept, then, with `exitOnQuit`, the end of the
332
+ process. Nothing else is synthesized — no About, Hide, Edit or Window
333
+ menu — so an app that wants them names them in `menus`.
334
+ - **Accelerators**: `Control` in a chord is ⌘, so `[['Control', 'S']]` is ⌘S;
335
+ there is no separate `Primary` token. The menu bar answers the chords it
336
+ can spell — one chord, one character key — and reserves them so no window
337
+ binding fires them twice; the rest (F5, `plus`, a second alternative) are
338
+ matched by the window ([events.md](events.md#the-macos-menu-bar)).
339
+ - Where native controls render, `<ContextMenu>`, a `<MenuBar>`'s dropdowns
340
+ and a `<Select>`'s drawn list are **sized as NSMenu is**: 22pt rows, 5pt
341
+ padding, 11pt separators, the 13pt menu font.
342
+ - **A `<Select>`'s list is the real NSMenu**, dropped from the trigger as a
343
+ browser drops a `<select>`'s; `nativeMenu` chooses
344
+ ([components.md](components.md)).
345
+ - The Dock menu and a tray item's menu take the same item vocabulary
346
+ ([desktop.md](desktop.md)).
1441
347
 
1442
348
  ## Custom drawing on a layer tree
1443
349
 
1444
- The escape hatches all reduce to one rule stated in Tier L: **a node
1445
- whose content is painted code gets a raster visual** — a bitmap-backed
1446
- layer, the CG canvas2d context translated to its box, redraw on the
1447
- node's own damage claims, `contentsScale` from the window. A claim that
1448
- names the node re-rasters that node; a bare rect — an element's
1449
- `invalidate(false, rect)` for the box a dragged item moved through, the
1450
- region `scrollContents` shifts, the strip an animation ticks in —
1451
- repaints every raster visual whose ink it touches, the same
1452
- conservative answer the damage model gives a rect on X11 — and only that
1453
- part of each: the raster keeps its bitmap as the visual's composition
1454
- cache, the pass clears and clips to the claim, and `paintDamage()` names
1455
- it, so an element culls a drag step or an animation tick the way it does
1456
- on X11 instead of replaying its whole scene into a clip.
1457
-
1458
- - `<canvas onDraw>`: `paintContent` runs against the CG ctx; a new
1459
- `onDraw` closure invalidates the node (existing rule); `putImageData`
1460
- keeps its absolute-coordinates contract via the same `info.x/y`.
1461
- - `cacheKey`/`mono`: the pattern transfers — keyed bitmaps shared
1462
- across canvases; `mono` renders an alpha-only mask once per
1463
- (name, size) and tints per ink via `CGContextClipToMask` + fill, so
1464
- the a8-coverage economics survive (ink stays out of the raster key).
1465
- - `registerElement` + `Node.paint(ctx)` subclasses (all seven components
1466
- elements): work unchanged through the raster visual. An element that
1467
- overrides `paint` — `super.paint(ctx)` for the box, then its scene —
1468
- has its content nowhere but in that override, so the raster replays
1469
- the override itself, with the child walk held back (the children have
1470
- visuals of their own). The `Context2D` dialect the consumers exercise
1471
- — including `fillRects`, `drawGlyphs`, `positioned`, `layoutSubtree`,
1472
- `vw/vh/em` — is part of the ctx contract on both backends.
1473
- - `ntk.Surface` offscreen allocation (charts, terminal, flow): the Cocoa
1474
- app object supplies one. `app.createSurface(options)` answers ntk's
1475
- `Surface` contract over a CG bitmap (`src/cocoa/surface.js`, issue
1476
- #433), and `react-x11/ntk`'s `Surface` asks the app it is handed before
1477
- falling through to ntk's pixmap, so a component allocates its buffer
1478
- the same way on both backends. `getContext('2d')` is the CG context,
1479
- `copyWithin` is one in-place copy of the surviving band (the bridge's
1480
- `scrollSurface`, ntk#252's exact contract, clamp for clamp), and
1481
- `ctx.drawImage(surface, …)` is one `CGContextDrawImage`. Sized in
1482
- device pixels like the window's backing store. Two things differ from
1483
- ntk and are stated where they live: a bitmap has one graphics state,
1484
- so a surface has one context for its life (`destroy()` on it is a
1485
- no-op) and `render()` brackets its callback in save/restore from the
1486
- identity; and `format: 'a8'` throws — the coverage surfaces the paint
1487
- cache and the shadows use stay on their X path until a consumer needs
1488
- them here. The subpath's other drawing-adjacent names
1489
- (`cssColorStraight`, `decodeImage`, `Image`) are pure JS already, and an
1490
- `Image` is a `drawImage` source here as well: its straight RGBA goes into
1491
- a CG bitmap once per `Image` (the bridge's `ctxPutImageData`
1492
- premultiplies) and composites like a surface, scaling included. The
1493
- X-only names stay X-only — `Picture`, and with it `<image picture>` and
1494
- `<image drawable>`, draw nothing here and warn once in development. An element that composites such a surface
1495
- over its box on **every** frame — a terminal, a chart on a socket — pays
1496
- that composite and the window swapchain's catch-up copy behind it to move
1497
- pixels it already has; making the element's surface its own layer instead,
1498
- the way `<glarea>` and layer promotion already do, is designed in
1499
- [architecture/element-layer-contents.md](architecture/element-layer-contents.md)
1500
- (#499) and not built.
1501
- - `<svg>`: the declarative vocabulary (`SvgChildNode`) is portable; the
1502
- rasterizer behind it renders through the same CG ctx.
1503
- - A future, deliberate seam — not built until a consumer needs it:
1504
- `registerElement({ visual })`, letting an element supply a
1505
- layer-aware visual instead of raster (a chart that wants its series
1506
- as shape layers, say). The raster default means nobody _has_ to.
350
+ `<canvas onDraw>`, `<svg>` and a registered element's `paint(ctx)` run
351
+ unchanged, through a CoreGraphics context that speaks the whole dialect:
352
+ canvas2d plus `fillRects`, `drawGlyphs`, `positioned`, `layoutSubtree` and
353
+ `vw`/`vh`/`em`.
354
+
355
+ **Offscreen surfaces.** `react-x11/ntk`'s `Surface` is a CoreGraphics bitmap
356
+ here, in device pixels. Unlike ntk:
357
+
358
+ - A surface has **one context for its life**: `getContext('2d')` answers the
359
+ same object every time, its `destroy()` is a no-op, and `render()` brackets
360
+ its callback in `save`/`restore` from the identity.
361
+ - `format: 'a8'` **throws**: allocate `argb32` and tint through
362
+ `fillStyle`/`globalAlpha`.
363
+ - `Picture`, `<image picture>` and `<image drawable>` are X server
364
+ resources: they draw nothing here and warn once in development. An `Image`
365
+ works.
366
+
367
+ **Context properties** that differ or matter here:
368
+
369
+ - **`globalCompositeOperation`** is canvas's whole set, including blend
370
+ modes (`multiply` and the rest) that X11 lacks. An op applies inside what
371
+ the draw covers, as on X11 and unlike a browser's `copy`. A bridge without
372
+ the verb refuses all but `source-over`: assign, then read it back. A
373
+ `drawGlyphs` call's `op` is ignored.
374
+ - **`'copy'` plus `drawImage(surface, x, y)`** at the surface's own size, a
375
+ whole-pixel translate, no alpha, no shadow and at most a rectangular clip
376
+ is a row memcpy, not a blend — the cheap way to present a surface every
377
+ frame (a 125×45 terminal grid in 0.42 ms rather than 1.37). Anything else
378
+ is drawn, with the same result.
379
+ - **`globalAlpha`** below 1 on a surface draw is cheap where
380
+ `ctx.fadesSurfacesCheaply` is true.
381
+ - **`imageSmoothingEnabled`/`imageSmoothingQuality`** are honoured; a context
382
+ starts at `'medium'`, not canvas's `'low'`. `'low'` makes a surface drawn
383
+ tile by tile through a matrix far cheaper (784 tiles: 200 ms → 27 ms).
384
+ - **`filter`** applies the colour functions (`grayscale()`, `sepia()`,
385
+ `saturate()`, `hue-rotate()`, `invert()`, `brightness()`, `contrast()`),
386
+ `opacity()`, and `blur()` on drawn images only; `drop-shadow()` and `url()`
387
+ do not stick. Read `filter` back to know.
388
+ - **`clip(rule)`** honours `'evenodd'`; **`roundRect`** takes canvas's radii,
389
+ `{ x, y }` corners included; **`createPattern`** is not supported.
390
+
391
+ An element can hand parts of its drawing to the render server as `sprites()`
392
+ ([extending.md](extending.md#parts-of-your-drawing-on-layers-of-their-own)).
393
+ One that composites its own surface over its box every frame — a terminal, a
394
+ chart on a socket — pays that composite and a copy behind it; a layer of the
395
+ element's own is [designed, not built](../design/architecture/element-layer-contents.md).
1507
396
 
1508
397
  ### Stroking a path with many subpaths
1509
398
 
1510
- `CGContextStrokePath` is **quadratic in the number of subpaths** in the
1511
- path it is handed. Measured on this machine — closed 13-vertex rings
1512
- scattered over a 1024×1024 surface, a 2px line, one `ctx.stroke()`:
1513
-
1514
- | rings | vertices | one stroke | chunked (what ships) |
1515
- | ----: | -------: | ---------: | -------------------: |
1516
- | 500 | 6,500 | 20 ms | 14 ms |
1517
- | 1,000 | 13,000 | 59 ms | 29 ms |
1518
- | 2,000 | 26,000 | 208 ms | 56 ms |
1519
- | 4,000 | 52,000 | 986 ms | 113 ms |
1520
-
1521
- The subpath count is the driver, not the vertex count: one 26,000-vertex
1522
- subpath strokes in 4ms where two thousand 13-vertex ones take 208. It was
1523
- found rasterizing vector map tiles — a `buildings` layer is one feature of
1524
- a couple of thousand building outlines, accumulated into one path
1525
- (react-x11 issue #456).
1526
-
1527
- So `CocoaContext2D.stroke` splits such a path into several
1528
- `CGContextStrokePath` calls, cut at subpath boundaries — a chunk closes at
1529
- the first `moveTo` past 512 points or 128 subpaths, which is within 5% of
1530
- the best chunk for every shape swept. **The backend picks the size, not the
1531
- caller**, because the right size is a fact about CoreGraphics that no
1532
- caller can know, and because the X11 context wants the exact opposite: there
1533
- a stroke is an a8 coverage mask over the path's bounding box uploaded with
1534
- one `PutImage`, so a bigger path is _fewer_ uploads over the same pixels,
1535
- and batching for one backend pessimizes the other.
1536
-
1537
- Two things are deliberately left whole, both because splitting them would
1538
- be a loss:
1539
-
1540
- - **A hairline** — a device-space width of 1 or less, so `lineWidth` times
1541
- the CTM's scale. Below that width CoreGraphics strokes through a path
1542
- that is already linear in the subpath count (the 2,000-ring path above
1543
- costs 7ms at 1px, whole), and splitting it is a **2× regression**: the
1544
- per-call setup with nothing to win back.
1545
- - **Anything that composites** — a translucent `strokeStyle`, a
1546
- `globalAlpha` below 1, a live shadow. Each chunk paints separately, so a
1547
- pixel that two subpaths' strokes each half cover is inked twice at half
1548
- coverage rather than once at full, and reads a little lighter. That is
1549
- the one visible difference chunking can make, and it is confined to the
1550
- seams where subpaths overlap under an opaque ink, where blending the same
1551
- colour twice changes only the antialiased fringe. `ctx.strokeChunking =
1552
- false` turns the split off for a caller that needs those seams exact, and
1553
- `REACT_X11_NO_STROKE_CHUNKING=1` for a whole process.
1554
-
1555
- `fill` is not chunked and cannot be: the winding rule is a property of the
1556
- path as a whole, so a hole in a shape stops being a hole once its ring is
1557
- in a different call.
1558
-
1559
- The path is recorded in JS alongside the native one so a chunk can be
1560
- re-issued — a flat number array, reused across paths. It costs an ordinary
1561
- paint nothing measurable (20,000 rounded-rect fill+strokes: 601ms before,
1562
- 597ms after; 20,000 twelve-segment polylines: 113ms before, 116ms after),
1563
- and the record is what puts the whole path back if a `fill`, a `clip` or
1564
- more path building follows a stroke that consumed it. `test/cocoa-stroke-chunking.test.js`.
1565
-
1566
- ### Compositing a surface at a translate
1567
-
1568
- An element that owns a surface presents it every frame: the terminal's
1569
- grid, a retained scene, anything that rasters once and shows the result
1570
- many times. `ctx.drawImage(surface, x, y)` is how it says so, and on this
1571
- backend that was `CGBitmapContextCreateImage` of the whole source plus
1572
- `CGContextDrawImage` — a CGImage built and a blend run, every frame, for
1573
- pixels that were only ever going to be copied. In
1574
- sidorares/react-x11-components#69 §6's profile it is 1.7ms of a 6ms
1575
- `<Terminal backend="vt">` frame, the second-largest line after the cell
1576
- background fills.
1577
-
1578
- The context takes canvas's own way of saying "do not blend, replace" —
1579
-
1580
- ```js
1581
- ctx.globalCompositeOperation = 'copy';
1582
- ctx.drawImage(surface, x, y);
1583
- ```
399
+ CoreGraphics strokes in time quadratic in a path's subpaths, so the context
400
+ splits a large stroke at subpath boundaries: a path of 4,000 rings strokes
401
+ in 113 ms rather than 986. Left whole: a hairline (1 device pixel or less)
402
+ and anything that composites — a translucent `strokeStyle`, `globalAlpha`
403
+ below 1, a shadow. The one visible difference: where two subpaths' strokes
404
+ overlap, the antialiased fringe reads a little lighter.
405
+ `ctx.strokeChunking = false` turns it off for one context,
406
+ `REACT_X11_NO_STROKE_CHUNKING=1` for the process. `fill` is never split.
1584
407
 
1585
- — and, where that is all it is, sends the composite as a row memcpy
1586
- (`blitSurface`) instead. `scripts/cocoa-composite-probe.mjs` prices the
1587
- two routes against the real bridge; on an M1 Pro, 200 composites a cell:
1588
-
1589
- | source | destination | CGImage draw | memcpy |
1590
- | --------------- | ----------------- | -----------: | ------: |
1591
- | 125×45 grid @2x | the whole surface | 1.37ms | 0.42ms |
1592
- | 125×45 grid @2x | a third of it | 0.36ms | 0.14ms |
1593
- | 125×45 grid @2x | one row's damage | 0.033ms | 0.012ms |
1594
- | 80×24 grid @2x | the whole surface | 0.35ms | 0.18ms |
1595
-
1596
- `globalCompositeOperation` itself is `ctxSetBlendMode`, and the vocabulary
1597
- is the **bridge's** rather than a table kept on this side: the names are
1598
- canvas's own, and the verb answers whether the mode is now the one asked
1599
- for. So this backend never goes stale against a bridge that grows an op,
1600
- and never claims one it would not draw. 0.7.0 knows the whole canvas set
1601
- — the Porter-Duff ops ntk maps to XRender's, plus the separable and
1602
- non-separable blend modes CoreGraphics has and XRender does not, which a
1603
- caller reaching for `multiply` should know are macOS-only until ntk grows
1604
- them. Two things about it are worth knowing. A bridge without the verb —
1605
- anything before 0.7.0 — **refuses** everything but `source-over` rather
1606
- than accepting it and drawing something else, so the detection a caller
1607
- writes is canvas's own: assign, then read the property back. An op
1608
- applies inside what the draw covers rather than across the whole surface —
1609
- a browser's `copy` clears everything the drawing missed, where
1610
- `kCGBlendModeCopy` and XRender's `Src` both leave it alone, so the two
1611
- backends agree with each other and differ from a browser together. And the
1612
- `op` argument a `drawGlyphs` call carries is still ignored; text composites
1613
- through the context's mode like everything else.
1614
-
1615
- The blit is an optimization and never a difference, so every way the
1616
- memcpy would not be exactly what the draw produces turns it off and the
1617
- draw happens: an op other than `copy`, a transform that is not a
1618
- whole-pixel translate, a `globalAlpha` below 1, a live shadow, a
1619
- destination size that differs from the source's, a fractional source
1620
- rect, and a surface composited into itself.
1621
-
1622
- The clip is the interesting one. A memcpy cannot see a CGContext's clip,
1623
- and a paint pass is clipped to its damage rect — so the context tracks
1624
- the clip **as a rect** beside the CTM it already tracked, and hands it to
1625
- the verb. It tracks only what it can state exactly: a single `rect` path
1626
- under an axis-aligned transform landing on whole pixels. A rounded
1627
- corner, an arc, a polygon, two rects, a half-pixel edge or a rotation all
1628
- resolve to "there is a clip and I cannot name it", which turns the blit
1629
- off until a `restore()` pops back past it. The clips a paint pass
1630
- actually sets — the damage rect, a square-cornered `overflow` — are the
1631
- ones it recognises.
1632
-
1633
- `test/cocoa-composite.test.js` pins both halves: the decisions over a
1634
- fake bridge — including a bridge with neither verb and one whose
1635
- vocabulary is smaller, which is what the feature detection is for — in
1636
- CI, and, where the real bridge loads, that the blit and the draw are
1637
- pixel-identical.
1638
-
1639
- ### Compositing a surface under an alpha
1640
-
1641
- CoreGraphics draws an image under an alpha below 1 at some fifteen times
1642
- what the same draw costs at 1, and composites a transparency layer the
1643
- same way. Neither the flip nor the interpolation is the cost, and nor is
1644
- the colour space, which is sRGB at both ends. So a group faded on a
1645
- surface of its own — `<box opacity>` (`NodePaint._paintGroup`), CSS
1646
- `opacity` in `@react-x11/components`' `<Html>` — cost more than drawing
1647
- each thing in it faded, which is the wrong answer wherever they overlap
1648
- (#810). A standalone benchmark of the bridge's own surfaces, a 556×300
1649
- card on an M1 Pro:
1650
-
1651
- | drawn as | ms |
1652
- | ----------------------------------------- | ---: |
1653
- | the card, each thing at alpha .6 | 0.63 |
1654
- | its surface at alpha 1 | 0.09 |
1655
- | its surface at alpha .6, the CGImage draw | 1.24 |
1656
- | a transparency layer at alpha .6 | 1.30 |
1657
- | its pixels scaled by .6, then drawn at 1 | 0.25 |
1658
-
1659
- The bridge's `ctxDrawSurfaceFaded` (`@windowkit/appkit` 0.18.0) is the last row:
1660
- `ctxDrawSurface` with the alpha passed again, since CoreGraphics keeps no
1661
- getter for the one `ctxSetGlobalAlpha` set, which scales the source rect's
1662
- premultiplied pixels into a bitmap of their own and draws that at 1. The
1663
- colours are the same within a unit a channel. The context sends a draw
1664
- there when its `globalAlpha` is below 1, the bridge has the verb and no
1665
- shadow would ink — a shadow is drawn from the image and the alpha
1666
- together — and says so as `fadesSurfacesCheaply`, beside `scalesText`, for
1667
- an element choosing between fading a group and fading each thing. The
1668
- bridge itself draws what is not that shape — a rect off the pixel grid, an
1669
- IOSurface source, a surface onto itself — through the CGImage under the
1670
- alpha, as before, and so does a bridge without the verb.
1671
- `test/cocoa-composite.test.js` pins the decisions over a fake bridge, and,
1672
- where the real bridge has the verb, that its colours are the draw's.
1673
-
1674
- ### Drawing a surface through a matrix
1675
-
1676
- Every context the bridge makes resamples an image at
1677
- `kCGInterpolationMedium`, and at medium CoreGraphics resamples the whole
1678
- source for a draw through a matrix, whatever the clip. A surface drawn a
1679
- tile at a time — a box in perspective, each tile under its own clip and the
1680
- matrix of the plane over it, as `<Html>` draws one — so costs each tile all
1681
- of the surface: 784 tiles of a 1400×1120 surface took 200ms on an M1 Pro,
1682
- and 27ms at `kCGInterpolationLow`, which is bilinear, what a layer in
1683
- perspective is drawn with. Keeping the CGImage the draw makes of the
1684
- surface between draws changed nothing.
1685
-
1686
- The context takes canvas's own properties for it —
1687
-
1688
- ```js
1689
- ctx.save();
1690
- ctx.imageSmoothingQuality = 'low';
1691
- // …the tiles…
1692
- ctx.restore();
1693
- ```
408
+ ### A shadow from a tile
1694
409
 
1695
- — `imageSmoothingEnabled`, false for the nearest pixel, and
1696
- `imageSmoothingQuality`, `'low'`, `'medium'` or `'high'`, over
1697
- `ctxSetImageSmoothing` (@windowkit/appkit 0.23.0, windowkit/appkit#109).
1698
- Both are state `save` and `restore` keep. A context starts at `'medium'`,
1699
- where the bridge's contexts were always made, and not at canvas's `'low'`:
1700
- an image drawn small keeps the quality it was drawn at. A bridge without
1701
- the verb keeps both where they start, so a caller knows whether an
1702
- assignment took by reading it back, as with `globalCompositeOperation`.
1703
-
1704
- ### A filter's colour functions
1705
-
1706
- `filter`, canvas's again, is a CSS filter list the context applies to what
1707
- it draws after it, and `save` and `restore` keep it:
1708
-
1709
- ```js
1710
- ctx.save();
1711
- ctx.filter = 'grayscale(1)';
1712
- ctx.drawImage(surface, x, y); // grey
1713
- ctx.restore();
1714
- ```
410
+ On macOS and Windows the context draws the shadow of a rect, a `roundRect`,
411
+ a `fillRect` or an inset frame of two from a **tile** made once and
412
+ stretched, shared by every card with the same shadow — a page header's inset
413
+ shadow went from 130–380 ms to 4 ms.
1715
414
 
1716
- The context applies the colour functions — `grayscale()`, `sepia()`,
1717
- `saturate()`, `hue-rotate()`, `invert()`, `brightness()`, `contrast()` —
1718
- and `opacity()` (`src/backend/filter.js`). Each of them leaves alpha as it
1719
- is, so it is a linear map of premultiplied colour, and drawing a shape in
1720
- the colour it makes of a fill's is drawing the shape and filtering it: a
1721
- fill, a stroke, a gradient's stops, a glyph run's ink and a symbol are
1722
- filtered so, at no cost. An image, a surface and a text layout that
1723
- carries colours of its own are filtered by their pixels — read with
1724
- `ctxGetImageData`, run through the matrices, put on a bitmap of their own
1725
- and drawn — which is a pass over the pixels on the JavaScript thread for
1726
- every draw. A shadow is cast after the filter, in its own colour, and
1727
- faded with what `opacity()` fades.
1728
-
1729
- `blur()` sticks too, and is applied to what is drawn as an image — a
1730
- `drawImage` of an image or a surface, which is what a renderer
1731
- composites a layer of its own with — and not to a fill, a stroke or
1732
- text, which are drawn sharp in the colours the rest of the list makes.
1733
- Its standard deviation is in the units the destination is given in, and
1734
- two `blur()`s are one. The source is read as far round as the blur
1735
- reaches, three deviations, scaled down by the bridge's resampling until
1736
- the deviation is two pixels, blurred there and run through the colour
1737
- functions, and drawn back up, smoothed: a Gaussian is smooth to the
1738
- scale it was taken at, which is how Skia blurs a large radius, and a
1739
- glass card's 64-pixel blur at 2x is a pass over a few thousand pixels.
1740
- Past the source is nothing, which the blur fades into, as canvas has it.
1741
-
1742
- `drop-shadow()` and a `url()` reach past what is drawn, which this does
1743
- not apply, so a list with one in it does not stick; nor does any on a
1744
- bridge without the pixel verbs. As with the other properties here, a
1745
- caller knows by reading `filter` back. `<Html>` draws a CSS `filter`'s
1746
- colour functions so where it sticks, and from a read back where it does
1747
- not — X11, whose server runs no colour matrix and answers a read a round
1748
- trip later.
1749
-
1750
- ### A clip's fill rule
1751
-
1752
- `clip()`, `clip(rule)`, `clip(path)` and `clip(path, rule)` are canvas's,
1753
- and the rule reaches `ctxClip` as the flag `ctxFill` takes:
1754
- `CGContextEOClip` for `'evenodd'` (@windowkit/appkit 0.24.0,
1755
- windowkit/appkit#111), and a winding geometry or an alternate one on
1756
- Windows (@windowkit/win32 0.0.7, windowkit/win32#12). ntk's SvgView cuts an
1757
- element to its `<clipPath>` with the shape's `clip-rule`, and a ring cut by
1758
- nonzero is the square around it. The rect the context keeps for
1759
- `blitSurface`'s memcpy does not depend on the rule: one rect is one
1760
- subpath wound once, which both rules cut alike.
415
+ - A tiled shadow is the canvas spec's gaussian (σ half the blur, three σ
416
+ wide), as Chrome and X11 draw it. Other shapes, rotations and composite
417
+ ops get CoreGraphics' own, which is a little narrower.
418
+ - `<box boxShadow>` draws on macOS and Windows.
1761
419
 
1762
- ### A shadow from a tile
420
+ `REACT_X11_NO_SHADOW_TILES=1`, or `ctx._shadowTiles = false` on one context,
421
+ puts every shadow back on CoreGraphics.
422
+
423
+ ## Animations and transforms
424
+
425
+ `transition` and `animation` styles work as everywhere. Where a layer can
426
+ express the property — a plain `<box>`'s `backgroundColor`, `borderColor`,
427
+ `borderWidth`, `borderRadius` and `opacity` — the **render server runs the
428
+ animation**: no JS frames, and it keeps moving while the JS thread is blocked
429
+ (`examples/animation.jsx` blocks it for two seconds). Everything else — text
430
+ colour, layout properties, a node that paints as a raster — runs on the frame
431
+ clock exactly as on X11.
432
+
433
+ - **Timing** matches the frame clock's easings.
434
+ - **Interruption**: a length retargets smoothly from where it is; a colour
435
+ restarts from the value on screen.
436
+ - **Not offloaded**: `boxShadow`. `transform` is not a style property on any
437
+ backend yet.
1763
438
 
1764
- CoreGraphics blurs a shadow on every fill that has one set, and what it
1765
- costs is the shape's area times the blur's width: a page header's
1766
- `box-shadow: inset 0 0 100px`, repainted by each strip a scroll exposed, was
1767
- 130 to 380 ms at 2x, and a card's 200px hover shadow 110 ms — every frame
1768
- the header or the card was in view. Direct2D does not draw a live shadow at
1769
- all; the Windows bridge records one and draws nothing.
1770
-
1771
- So the context (`src/backend/context2d.js`, both bridges) recognises the
1772
- shapes a box shadow is made of — a `fill` of a path that is one `rect` or
1773
- one `roundRect`, or two of them one inside the other filled `'evenodd'`,
1774
- which is the frame an inset shadow is cast around, and a `fillRect` — and
1775
- draws their shadow from a **tile**. The shadow of a rounded rect is the
1776
- same all along its straight edges, so it is made once, for a copy of the
1777
- shape shortened to three pixels of straight edge on each axis that has
1778
- room, and drawn as up to nine pieces: the corners as they are, the
1779
- straight pixel stretched between them. The tile is named by its corners,
1780
- blur, hole and colour — not by where the shape is or what part of it a
1781
- paint reaches — so every card with one shadow shares a tile, and a strip a
1782
- scroll exposes draws a strip of it. ntk plans the tile and makes its
1783
- pixels (`ntk/shadow-tiles`, the same code X11's shadows come from): the
1784
- shape's coverage and a gaussian of σ `shadowBlur / 2`, made smaller for a
1785
- wide blur and scaled up as it is drawn. The header's inset shadow is 4 ms
1786
- where CoreGraphics took 130 to 380, and the tiled shadow is within six
1787
- levels of 8-bit alpha of the exact one (`test/backend-shadow-tiles.test.js`,
1788
- over the real bridge where it loads). What a tile cannot draw — a path of
1789
- anything else, a transform that rotates, a composite other than
1790
- `source-over` — is the bridge's, as before.
1791
-
1792
- Two things follow that are visible:
1793
-
1794
- - **A shadow a tile draws is the canvas spec's gaussian**, σ half the
1795
- blur, three σ wide — the one Chrome and X11 draw. CoreGraphics' own blur
1796
- is a little narrower, about 0.45 of the blur and cut off at the blur, and
1797
- it is still what a shape a tile does not draw gets.
1798
- - **`<box boxShadow>` draws on macOS and Windows.** It was baked on an X
1799
- server and drew nothing without one. Without one it is the context's own
1800
- shadow now, which is to say a tile.
1801
-
1802
- `REACT_X11_NO_SHADOW_TILES=1`, or `ctx._shadowTiles = false` on one
1803
- context, puts every shadow back on the bridge, for measuring the two
1804
- against each other.
1805
-
1806
- A `roundRect` takes its radii as canvas has them: a number, a
1807
- `{ x, y }` point for an elliptical corner, or a list of one to four of
1808
- either, scaled down together where two corners on a side would meet. It
1809
- took numbers alone, clamped each on its own, and drew a `{ x, y }` corner
1810
- square. Circular corners go to the bridge as `ctxRoundRect`; elliptical
1811
- ones as `ctxRoundRectXY` where it has one — and as op 9 in the one-call
1812
- `ctxPath` stream — and as curves where it does not, so an older bridge
1813
- draws them too.
1814
-
1815
- ## Animations and transforms: the API the model unlocks
1816
-
1817
- The existing declarative vocabulary is the seam: `transition:` and
1818
- `animation:` in styles. On X11 they run on the frame clock (JS
1819
- interpolation, repaint per frame — including the premultiplied-lerp
1820
- rule), and so do they on the surface presenter. **The layer presenter
1821
- hands them to the render server** where the node's own layer expresses
1822
- the property — a plain `<box>`'s `backgroundColor`, `borderColor`,
1823
- `borderWidth` and `borderRadius` — and schedules no frames for them: the
1824
- style goes to its target, which is the layer's model value, and the frame
1825
- that sends it attaches an explicit animation carrying the pixels there
1826
- (`CocoaLayerPresenter.animate`, `@windowkit/appkit` >= 0.5). A loop is one
1827
- repeating animation and costs no JS frames at all. Everything else — a
1828
- colour on text, a layout property, any node that paints as a raster — stays
1829
- on the frame clock, byte-identical to before; the presenter can only decline,
1830
- never break. **The surface presenter takes the same animations** by
1831
- promoting the node to a layer of its own for as long as it animates —
1832
- the next section — so `examples/animation.jsx` is the demonstration on
1833
- either: its frame counter reads 0 while the plain-box loops keep going,
1834
- and they keep going through a deliberate two second block of the JS
1835
- thread.
1836
-
1837
- The three semantics [the design](architecture/animation.md) said to pin:
1838
-
1839
- - **Timing parity** is control points, not names. `transition`'s ease-out
1840
- is cubic-bezier(0.33, 1, 0.68, 1) to within 0.003 and the loop easings
1841
- map the same way (`EASING_CONTROL_POINTS`, styles.js), where CA's named
1842
- `easeOut` is a different curve — test/style.test.js pins both facts.
1843
- - **Interruption**: a length retargets _additively_ — the model goes to the
1844
- new target and a delta animation `(old − new) → 0` joins the one still
1845
- running — so a change of mind mid-flight is continuous with nothing read
1846
- back; a colour cannot be additive and restarts from the presentation
1847
- value the bridge reports.
1848
- - **Completion** is the bridge's `animation-end` event, so the node model
1849
- drops the entry when the render server says so, not when a timer guesses.
1850
-
1851
- Not offloaded yet, in the order they would pay: transforms (they do not
1852
- exist as a style property — the design's §3.4) and `boxShadow`'s
1853
- components. `opacity` (#601, offloaded in #818) and, for a node no pointer
1854
- lands on, an out-of-flow node's insets as `position` have since joined the
1855
- list above (`ANIMATED_KEY_PATHS`, `INSET_KEY_PATHS`, src/cocoa/presenter.js).
1856
-
1857
- Two style capabilities currently rejected everywhere become cheap on
1858
- layers and are proposed as **capability-gated additions** rather than
1859
- macOS-only forks, keeping one style vocabulary:
1860
-
1861
- - **`opacity`** — free on a layer (`opacity`); on X11 implementable as
1862
- group-raster through an ntk `Surface` at composite time (bounded,
1863
- cacheable). Ship on both, the X11 cost documented. _Shipped that way
1864
- (#601): a style property on every backend, a group faded as one._
1865
- - **`transform`** (2D: translate/scale/rotate) — free on a layer;
1866
- XRender pictures do transform, so a bounded X11 story exists for
1867
- leaf-ish content even if the general case (transformed subtrees with
1868
- events) is deferred. Proposed staged: layer backend first behind
1869
- `useSupports('transforms')` + an `@supports` style block, X11 catch-up
1870
- or documented divergence decided by real demand.
1871
-
1872
- Both wait until the presenter exists; they are listed because "hardware
1873
- composited layers with animation and transformations" is half the reason
1874
- to build Tier L, and the API shape (style props + capability gates)
1875
- should be agreed before someone builds a macOS-only thing.
439
+ On the default presenter the offload is
440
+ [layer promotion](#layer-promotion-the-animated-few-on-their-own-layers-above-the-surface-presenter).
441
+ `cocoa: { presenter: 'layers' }` gives every drawn node a CALayer instead; it
442
+ is opt-in while it is measured, and a pan there re-rasters the whole scene.
1876
443
 
1877
444
  ## Layer promotion: the animated few on their own layers above the surface presenter
1878
445
 
1879
- The presenter choice above is all-or-nothing, and for the shape of app
1880
- that most wants the retained tier — a large scene with one or two
1881
- animated elements in it — neither answer was right. `surface` could not
1882
- offload an animation at all: every transition frame was a JS repaint plus
1883
- an upload, and a stalled JS thread stopped the animation dead. `layers`
1884
- offloaded it and paid for the whole rest of the scene: a pan is a
1885
- whole-scene re-raster (`scrollRegion` is nulled in layers mode), and every
1886
- raster visual carries its own bitmap. The hybrid is what browsers call
1887
- **layer promotion**, and it is the surface presenter's default (issue
1888
- #483): keep the bitmap for the scene, and give the handful of nodes that
1889
- actually animate a CALayer of their own above it. `src/cocoa/promotion.js`
1890
- is the whole of it.
1891
-
1892
- **Why it was cheap here.** The mechanism already shipped, for `<glarea>`.
1893
- The surface presenter's pixels are the window root layer's `contents`,
1894
- and a layer's contents draw _below_ its sublayers — so a promoted sublayer
1895
- composites over the bitmap for free, and there was no compositing to
1896
- write. The hole in the 2D raster was already an idiom (`GlAreaNode.paint`
1897
- is empty): a promoted node is skipped by the paint walk (`Node._promoted`),
1898
- so the walk paints what is behind it and nothing else. The animation
1899
- seam was already presenter-agnostic — `animateNode` / `cancelNodeAnimation`
1900
- are feature-detected on the window, and `_offloadDeclined` hands a
1901
- property back to the frame clock whenever a presenter cannot take it — and
1902
- the bookkeeping of what the render server runs was one class
1903
- (`LayerAnimations`, presenter.js) once it was lifted out of the layer
1904
- presenter; the two differ only in which node has a layer.
1905
-
1906
- **What a promoted node is.** A property box — its background, border and
1907
- radius as properties of its layer, the same `propBoxProps` the layer
1908
- presenter sends, which is exactly the vocabulary the render server
1909
- animates. Its children, if it has any, ride the layer as one raster
1910
- sublayer painted by the node's own `_paintChildren` walk (a `Visual` and a
1911
- `RasterState` from the layer presenter, used standalone), repainted for the
1912
- claims that reach into it and for a change in where the children sit —
1913
- the hover card in `examples/animation.jsx` promotes with its two captions.
1914
- Its `opacity` is the layer's as well, and the layer is then the group CSS
1915
- asks for: Core Animation draws a layer under an opacity below 1 with its
1916
- sublayers and fades them as one (`allowsGroupOpacity`, YES by default on
1917
- macOS), which is what `NodePaint._paintGroup` draws on the bitmap — read
1918
- back as 1 off a promoted layer's presentation layer, and a half-faded card
1919
- over a red child snapshotted with no blue in the red. A promoted node
1920
- inside a promoted node leaves a hole in its parent's raster and sits on a
1921
- layer of its own above it, and coming off its layer it is painted into
1922
- that raster again in the same frame (#817: what gives it back — the grace
1923
- running out, a fade above it — claims its own layer or nothing, so no
1924
- claim would have); every promoted layer is flat on the window root,
1925
- ordered by paint order. Hit testing never moves: the node tree stays the
1926
- source of truth for input on every presenter, so promotion changes pixels
1927
- and nothing else.
1928
-
1929
- **The policy** is not inferred from the scene, and there is no style prop
1930
- to get wrong: a node is promoted because it has a transition or a loop on
1931
- a property a layer can express — a plain `<box>`'s `backgroundColor`,
1932
- `borderColor`, `borderWidth`, `borderRadius`, `opacity` — for as long as
1933
- it has one and a second after, and returns to the bitmap then. A loop on
1934
- an inset of a box out of the flow is one as well, where no pointer lands
1935
- on the box (`position: 'absolute'`, `pointerEvents: 'none'`): it moves
1936
- that box and nothing else, so it is the layer's position, an offset added
1937
- to where the layout rests the box, and a percentage is a share of the
1938
- parent's padding box, run again where it has got to when the parent's size
1939
- changes (`LayerAnimations.follow`). The pointer is the condition because
1940
- input is hit against the layout's rect, which holds still while the layer
1941
- moves. `<ProgressBar indeterminate>`'s block is one: a bar cost a layout
1942
- pass and a frame at the display's rate, 16% of a core on an M1 Pro at
1943
- 75 Hz, and costs nothing now. The second is
1944
- `IDLE_GRACE_MS`: a hover card fades in and, a moment later, out; a palette
1945
- step ends one transition and starts the next; a toast pulses again.
1946
- Demoting on the last frame of each cost a layer and a repaint of the hole
1947
- per round trip — the first run of the bench made 96 layers for 48 cards —
1948
- and a second is longer than any such gap. Nothing can promote two hundred
1949
- nodes by mistake, and there is nothing to document on a backend that may
1950
- decline it.
1951
-
1952
- **The hard part is z-order.** A promoted layer sits above _all_ the 2D
1953
- content, so promotion is only correct where nothing paints over the node.
1954
- Browsers answer the general case with overlap testing that cascades
1955
- promotions upward through the stacking context — precisely the machinery
1956
- worth not having. The cheap rule instead: a node is promoted only when
1957
- nothing painted after it in the walk reaches into its bounds — no later
1958
- sibling at any level (a later subtree counts where it puts ink, so a
1959
- layout-only container laid over the node is no overlap, and a later
1960
- subtree that is itself promoted is on a layer above and does not count),
1961
- no ancestor's border ring or focus ring (both are painted after the
1962
- children), and no ancestor that fades, because the layer would not fade
1963
- with it: a box inside a faded box stays in the
1964
- group's pixels, on the clock, where a layer on the root was drawn at full
1965
- strength until #817. A clipping ancestor cuts the layer to its box: the
1966
- layer goes in a box of the clip's size that masks to it, the way an
1967
- element's part is cut (below), so a row half scrolled out of its list is
1968
- promoted, and one none of which shows is kept on a layer that shows
1969
- nothing. The innermost clip with round corners that does not hold the node
1970
- gives the box its corners, and a square clip that cuts that one again is a
1971
- box around the box, as for a part. All of it is answered from
1972
- `paintOrder()` and the cached paint reach, and the same test runs again
1973
- every frame, later-painted nodes first, so a node that becomes
1974
- overlapped, hidden, clipped round, faded around or non-plain (a `:hover`
1975
- that adds a shadow) returns to the bitmap in the frame that finds it, the
1976
- animation handed back to the clock. Overlays, toasts, drag ghosts,
1977
- spinners, floating cards and the rows of a list pass by construction.
1978
- Declining is always safe — the frame
1979
- clock runs the animation exactly as it does with promotion off — and a
1980
- refusal is remembered until the next layout, so the same scene does not
1981
- cost a frame's decline per retarget.
1982
-
1983
- **A scrollbar's thumb goes above the rows it is drawn over.** The bars
1984
- are painted after a pane's children, over them, and the thumb is a bar's
1985
- only ink — its track is not drawn. So a row under the thumb is no reason
1986
- to refuse the row: the thumb goes on a layer of its own instead, a colour
1987
- and a radius as core paints it, ordered after everything inside the pane
1988
- and before whatever is painted after it, and the bitmap stops painting it
1989
- (`Scrollable._paintScrollbars`). That is only where nothing painted after
1990
- the pane reaches anywhere the thumb can go, nothing above the pane fades
1991
- and every clip above it holds the track whole — `_clear` asked of the
1992
- pane at its tracks — and the thumb is back in the bitmap in the frame no
1993
- layer is under it any more. Without it every row of a list was under the
1994
- track, and a list of pulsing rows was the clock's at the display's rate.
1995
-
1996
- **Not while the pane scrolls.** A node inside a pane that scrolled in the
1997
- last 250 ms gets no layer, keeps none and is offered none (`_scrolling`):
1998
- its layer would move every frame the pane is painted anyway, and the
1999
- pane's blit carries it in the bitmap. The window marks the panes a frame
2000
- scrolls (`_pendingScrolls`), and promotion notes them before anything
2001
- else, since a wheel steps a pane 60 times a second against a display's 75
2002
- or 120 and the frames between its steps are still the scroll. A refusal
2003
- for it is not remembered for the layout, as the others are, since a
2004
- scroll ends without laying anything out. The case is a list whose rows
2005
- fade on hover: a wheel brings row after row under the pointer, and each
2006
- kept a layer for the grace, was asked every frame whether it might stay
2007
- and had its raster synced as the pane moved it. On components' 100,000-row
2008
- `<Table>` that cost a wheel 29% more of every frame than painting the
2009
- fades in the bitmap did, 57% of a core to 66%. A loop on a row comes off
2010
- with the rest, carries on on the clock where it had got to, and is offered
2011
- again a quarter of a second after the pane stops. So a pane that code
2012
- scrolls every frame, a ticker or a smooth scroll of its own, keeps
2013
- everything in it on the clock for as long as it moves: its layers would
2014
- have moved every frame too.
2015
-
2016
- **A loop the clock runs is offered again.** Nothing about a loop ever asks
2017
- twice — a transition ends and the next one is offered afresh, but a loop
2018
- turned down at its start would run on the clock, a frame every refresh,
2019
- for as long as it runs. So after each frame's layout the window offers
2020
- every loop the clock runs to the presenter again, at most every 250 ms and
2021
- only where the scene would take it now (`wouldTake`): a fading ancestor
2022
- arrived at 1, a sibling moved away. It comes over
2023
- where it is in its cycle — the render server's animation starts that far
2024
- in — and a loop given back carries on from where the render server had
2025
- it, so a spinner that changes hands does not jump either way.
2026
-
2027
- **The frame.** `WindowNode.flush()` gives the presenter its word in after
2028
- layout and before the damage is taken (`window.prepareFrame`, feature-
2029
- detected like `presentFrame`): whatever it moves onto or off a layer
2030
- claims the bitmap under it in that same frame, so a node is never painted
2031
- at its target before a declined animation restarts from its start, and a
2032
- demoted node is back in the bitmap in the frame its layer goes. A
2033
- promoted node's own claims — a retarget, a hover flip, a caption changing
2034
- inside it — are answered on the layer and cost the bitmap nothing: the
2035
- `noteInvalidate` channel the layer presenter listens on answers `true`,
2036
- `WindowNode.invalidate` records a frame with nothing in it for the bitmap,
2037
- and the frame runs the presenter's half and paints no pixel (`hovertree`
2038
- below: 0kpx a frame). The scroll blit's safety gate skips a promoted node
2039
- for the same reason — its pixels are not in the bitmap the band is cut
2040
- from — which is what makes a pan under a pulsing toast a blit
2041
- (`pananim`), on a backend where the toast's per-frame claim used to poison
2042
- every notch.
2043
-
2044
- **What to expect, and how to stop it.** An animation on a plain box
2045
- costs one frame to start and, a second after the last one ends, one to
2046
- come back, and the render server draws every frame between — through a
2047
- busy JS thread, at the display's rate — while the bitmap keeps the frame
2048
- for everything else, the scroll blit included. What it costs is a CALayer
2049
- and, for a node with children, a bitmap the size of their reach, for as
2050
- long as the node animates and the second after. A fade is one of them: a
2051
- 200×120 card with an `opacity` loop over a child painted 120 frames in two
2052
- seconds on the clock, and paints none on its layer.
2053
- `createRoot({ cocoa: { promote: false } })` or `REACT_X11_COCOA_PROMOTE=0`
2054
- keeps every animation on the frame clock, which is what the presenter
2055
- bench's `surface` column measures; `true` / `=1` turns it on regardless
2056
- of the bridge. `test/cocoa-promotion.test.js` pins the contract over the
2057
- fake bridge; `test/animation-example.test.js` walks the example through
2058
- it.
2059
-
2060
- **An element's parts** (#819). A registered element whose content is its
2061
- own `paint` has no plain box to promote, so it offers the parts of its
2062
- drawing it would like lifted — `sprites()`, asked every frame like
2063
- `opaqueRect()` ([extending.md](extending.md#parts-of-your-drawing-on-layers-of-their-own)).
2064
- Each lifted part is a layer of its own on the window root, in the same
2065
- paint order as the promoted nodes (`src/cocoa/sprites.js`): its content a
2066
- raster painted once by the part's own `paint`, its opacity and its
2067
- transform — a matrix, which the bridge takes from `@windowkit/appkit` 0.19,
2068
- or a matrix3d for a part turned out of the plane, which Core Animation draws
2069
- in its perspective on the GPU — properties of the layer, and its animations
2070
- keyframes the render server runs. The rules are the ones above, asked of the element at everywhere the
2071
- part can be over its animations, with the element's own fade added to its
2072
- ancestors': the element vouches for its inside, and leaves a hole where a
2073
- lifted part is from the frame `spritesLifted` tells it so. A part given back
2074
- is the element's to draw again in that same frame, with the bitmap under it
2075
- claimed. A registered element offering a card with an opacity loop, a turn
2076
- sampled into 61 matrices, or a static half-faded quarter turn painted no
2077
- window frame and no paint of the element in two seconds on this machine,
2078
- with the render server's values moving under it. A part may name another
2079
- as its `parent`: its layer is then a sublayer of the parent's, placed from
2080
- the corner of the parent's raster, which the presenter paints again
2081
- without it — handing the parent's `paint` the keys lifted inside it —
2082
- whenever that set changes. It is asked about nothing at the window's
2083
- level, since the parent was, and only kept to the parent's raster.
2084
-
2085
- **One thing it found, and the bridge it needs.** A promoted node is the
2086
- same node drawn two ways in turn, and the two had to agree to the pixel
2087
- or every hover would flash. They did not: a layer's `backgroundColor` went
2088
- through `@windowkit/appkit`'s `CGColorCreateGenericRGB` while its surfaces
2089
- are sRGB, so `#dbe7f4` rastered as (219, 231, 244) and composited as
2090
- (228, 236, 245) — paler, on every layer the layer presenter ever set, and
2091
- a colour animation there landed on a model value that did not match its
2092
- own `to`. [windowkit/appkit#33](https://github.com/windowkit/appkit/pull/33)
2093
- makes every colour crossing the bridge sRGB and adds `colorSpace()` to
2094
- say so, and shipped as `@windowkit/appkit` 0.5.1, the range this package
2095
- asks for; against it the same card differs by at most six units of one
2096
- channel, in the antialiasing. Promotion is on by default **only where the
2097
- bridge answers `'sRGB'`** — on 0.5.0 it stays off unless asked for — so
2098
- nobody gets the flash for free, and the bridge upgrade is what turned it
2099
- on.
2100
-
2101
- **And one a wide-gamut display found** (#820). The surface presenter shows
2102
- the window through an IOSurface, and that IOSurface named no colour space.
2103
- Core Animation shows such a surface as the display's own numbers, and
2104
- colour-matches everything that does name one: a promoted layer's `CGImage`,
2105
- its `backgroundColor`. So on a display whose profile is not sRGB — a
2106
- MacBook's built-in panel, a monitor carrying its own EDID profile — the
2107
- window was more saturated than its CSS colours, and a promoted node changed
2108
- colour on its way onto its layer and again on its way back: `#3b82f6` read
2109
- (59, 130, 246) in the bitmap and (78, 128, 238) on its layer on the
2110
- built-in panel. `@windowkit/appkit` 0.19.0 and every release after it,
2111
- which this package asks for, name every IOSurface they make sRGB, and
2112
- `setLayerContentsIOSurface` names one that names nothing, which covers a
2113
- `<glarea>`'s x11-dri frames too (windowkit/appkit#98, #99). On it the
2114
- bitmap, a promoted layer, a `<Frame>` pane and a `<glarea>` read the same
2115
- numbers on all three displays measured. Three consequences:
2116
-
2117
- - **Every window on such a display is colour-managed now**, as Safari and
2118
- Chrome manage a page, and looks less saturated than it did before 0.19.0:
2119
- correct, and visibly different.
2120
- - **Opaque colours agree to the pixel; translucent ones do not quite.**
2121
- Core Animation blends a layer in the display's space, where the bitmap
2122
- blends in sRGB before it is matched, so `rgba(255, 0, 0, 0.5)` over white
2123
- reads (239, 134, 131) in the bitmap and (245, 153, 145) on a layer.
2124
- Neither the bridge nor core can name that space away, and it matters most
2125
- for a node given back while it rests at a partial opacity.
2126
- - **`window.snapshot()` reads the display's numbers for the bitmap**, as it
2127
- already did for a layer, and the PNG carries the display's profile: red
2128
- is (234, 51, 35) on the built-in panel. A script that compares raw
2129
- snapshot numbers with CSS colours sees the shift.
2130
-
2131
- **Measured**, 2026-09-06, this machine (a 120Hz panel; the window on a
2132
- 60Hz monitor where it says 59fps), 4s per cell, `npm run bench:presenters
2133
- -- --scenario=anim,animtree,hovertree,pananim,scroll,tree`; `surface` is
2134
- promotion off, `promoted` on. Frames are what the JS side painted;
2135
- `ticks` are the scenario's own changes (a palette step every 15, a hover
2136
- flip or a wheel notch every one).
2137
-
2138
- | scenario | column | frames / ticks | flush avg / p95 | damage a frame | cpu | what it says |
2139
- | ----------- | -------- | -------------- | --------------- | ------------------ | ---- | -------------------------------------------------- |
2140
- | `anim` | surface | 455 / 235 | 2.17 / 2.51ms | 2170kpx | 46% | every frame of every fade, all 48 cards |
2141
- | | promoted | 16 / 241 | 2.80 / 6.92ms | 136kpx | 17% | one frame a palette step; 48 layers, made once |
2142
- | | layers | 16 / 241 | 2.21 / 3.26ms | — | 17% | |
2143
- | `animtree` | surface | 208 / 120 | 14.58 / 16.97ms | 2031kpx | 108% | cannot keep up: the fades repaint the grid |
2144
- | | promoted | 243 / 243 | 0.91 / 1.87ms | 13kpx | 54% | the grid's one cell a tick; the fades cost nothing |
2145
- | | layers | 244 / 244 | 2.75 / 3.75ms | — | 67% | a visual per node of the grid |
2146
- | `hovertree` | surface | 476 / 242 | 0.28 / 0.38ms | 43kpx | 14% | input→flush p50 2.80ms |
2147
- | | promoted | 242 / 242 | 0.31 / 0.39ms | 0kpx | 11% | p50 2.86ms; the bitmap paints nothing |
2148
- | | layers | 241 / 241 | 1.38 / 1.65ms | — | 22% | p50 2.75ms |
2149
- | `pananim` | surface | 702 / 243 | 0.65 / 1.82ms | 817kpx, 31% full | 24% | the toast's claim poisons every notch's blit |
2150
- | | promoted | 219 / 242 | 1.57 / 2.01ms | 525kpx, 0% full | 22% | the blit, exactly `scroll`'s |
2151
- | | layers | 220 / 243 | 1.89 / 2.28ms | 2520kpx, 100% full | 17% | a whole-scene re-raster a notch |
2152
- | `scroll` | promoted | 220 / 243 | 1.45 / 1.86ms | 525kpx | 20% | as surface: 1.49 / 2.01ms, 525kpx, 21% |
2153
- | `tree` | promoted | 243 / 243 | 0.47 / 0.65ms | 4kpx | 49% | as surface: 0.42 / 0.57ms, 4kpx, 47% |
2154
-
2155
- So both halves at once: the animation's frames match `layers`, the flush
2156
- and the scroll match `surface`, and `animtree` and `pananim` beat both.
2157
- The 54% left in `animtree` is the React commit of a 3,700-node tree per
2158
- tick, which `tree` prices at 47% with no animation at all. The gate keeps
2159
- it: `scripts/bench/presenters-gate.json`'s `promoted` rules judge the
2160
- promoted column on frames per tick, full frames and damage share, next to
2161
- the surface column's own.
446
+ The default presenter keeps one bitmap per window and gives the few nodes
447
+ that animate a CALayer of their own above it while they do. Hit testing never
448
+ moves: promotion changes pixels and nothing else. With no prop to set, a node
449
+ is promoted when it is
450
+
451
+ - a plain `<box>` with a transition or a loop on `backgroundColor`,
452
+ `borderColor`, `borderWidth`, `borderRadius` or `opacity` — for as long as
453
+ it has one and a second after; or
454
+ - a box out of the flow that no pointer lands on (`position: 'absolute'`,
455
+ `pointerEvents: 'none'`) with a loop on an inset, which moves it and
456
+ nothing else — `<ProgressBar indeterminate>`'s block costs no frames;
457
+ - and **only where nothing paints over it**: no later sibling's ink in its
458
+ bounds, no ancestor's border or focus ring, no ancestor that fades. A
459
+ clipping ancestor, round corners included, clips the layer. A node in a
460
+ pane that scrolled in the last 250 ms waits until the pane stops.
461
+
462
+ A node that stops qualifying returns to the bitmap in the frame that finds
463
+ it, and its animation carries on on the frame clock from where it was. A
464
+ scrollbar thumb over promoted rows goes on a layer of its own.
465
+
466
+ It costs a frame to start, one to come back a second after the last
467
+ animation ends, and meanwhile a CALayer, plus for a node with children a
468
+ bitmap the size of their reach. `cocoa: { promote: false }` or
469
+ `REACT_X11_COCOA_PROMOTE=0` keeps every animation on the frame clock; `true`
470
+ or `=1` forces it on whatever the bridge (it is on by default where the
471
+ bridge reports sRGB colours, 0.5.1 and later).
472
+
473
+ **On a wide-gamut display** (a MacBook's built-in panel, a monitor with its
474
+ own profile) every window is colour-managed, as a browser manages a page, so
475
+ it looks less saturated than an unmanaged one. Opaque colours agree to the
476
+ pixel between bitmap and layer; translucent ones differ slightly, because
477
+ Core Animation blends in the display's space. `window.snapshot()` reads the
478
+ display's numbers and carries its profile, so a script comparing raw
479
+ snapshot pixels with CSS colours sees a shift.
480
+
481
+ Design notes and measurements:
482
+ [design/macos.md](../design/macos.md#layer-promotion-the-animated-few-on-their-own-layers-above-the-surface-presenter).
2162
483
 
2163
484
  ### Video on a layer
2164
485
 
2165
- A `<video frames>` ([elements.md](elements.md#video)) is the first part
2166
- whose layer shows a **source** rather than a raster: its sprite's
2167
- `contents` is the sink, and `src/cocoa/video.js` puts every pushed frame
2168
- into a video surface — an IOSurface the render server scans out as it is,
2169
- NV12 for a YCbCr sink, BGRA for a BGRA one (`createVideoSurface`,
2170
- windowkit/appkit#103) — and repoints the layer inside the `push`. The
2171
- window paints nothing for it: on the real bridge, threaded, sixty pushes
2172
- of a 320x180 NV12 sink cost the window one frame, the one that lifted it.
2173
- The surfaces are a ring of up to four, written only when
2174
- `videoSurfaceIsInUse` says the render server has let one go.
2175
-
2176
- Declined — a box painted over it, a fade, a rounded clip — the element
2177
- draws the newest frame into a surface of its own through
2178
- `writeVideoSurface`, which converts a YCbCr frame in the colours the layer
2179
- shows it in. Both are the colours `AVPlayerLayer` shows the frame in: a
2180
- surface with CoreVideo's tags alone is linearised with the exact 709 curve,
2181
- where the player, VideoToolbox and Core Image use Apple's 1.961 gamma, so
2182
- the bridge names each surface's colour space from its tags and converts
2183
- through VideoToolbox. Read back through `snapshotWindow`, the same frame
2184
- lifted, drawn and played differs by at most two levels a channel. A
2185
- bridge without the verbs lifts nothing, and the frame is converted in
2186
- JavaScript instead (`app.videoFormats()` then says `BGRA` first).
2187
-
2188
- A `<video src>` is the second source: an `AVPlayer` over the file or URL
2189
- (`createPlayer`, `src/cocoa/player.js`) is the
2190
- part's `contents`, and lifted, its own `AVPlayerLayer` goes inside the part's
2191
- layer and fills it — AVFoundation puts every frame on the screen, and on the
2192
- real bridge a playing 30fps clip cost the window no frames at all. Declined,
2193
- the element asks `playerCopyFrame` for the frame showing every display frame
2194
- while it plays, which answers only when there is a new one, so a covered
2195
- 30fps clip is thirty frames a second of the window's, each bounded to the
2196
- picture, in the colours the player's layer shows. Over a bridge without the
2197
- verbs `createPlayer` is absent from the app, `useSupports('mediaPlayback')`
2198
- is false, and `src` refuses as it does on X11. A `<Frame>` pane runs no
2199
- AppKit, so nothing there would ever hear a player's events: its host plays
2200
- its videos for it and sends the frames down (docs/frame.md, "A pane's
2201
- videos").
2202
- docs/architecture/video.md is the design record.
486
+ A [`<video>`](elements.md#video) goes on a layer of its own under the same
487
+ rule: nothing painted over it, no fade, no rounded clip that cannot hold it.
488
+
489
+ - **`<video frames>`**: lifted, each pushed frame goes straight to a surface
490
+ the render server shows, at no cost to the window; declined, the element
491
+ draws the newest frame into the window. The colours are `AVPlayerLayer`'s.
492
+ Over a bridge without the video-surface verbs the frame is converted in
493
+ JavaScript, and `app.videoFormats()` lists `BGRA` first.
494
+ - **`<video src>`** plays through `AVPlayer`: lifted, its player layer shows
495
+ every frame at no cost to the window; covered, each new frame is copied
496
+ into the window. Over a bridge without the player verbs
497
+ `useSupports('mediaPlayback')` is false and `src` refuses, as on X11. In a
498
+ `<Frame>` pane the host plays the video and sends the frames down.
2203
499
 
2204
500
  ## Running as an app bundle
2205
501
 
2206
- A react-x11 process is a `node` (or `bun`) process, and AppKit names, icons
2207
- and identifies an app by the **bundle its executable lives in**: run from the
2208
- shell, the menu bar says "node", the Dock shows the generic icon, and the
2209
- defaults domain — where AppKit remembers window frames and whether a tab
2210
- bar is showing — is `node`'s or `bun`'s, shared with every other script
2211
- that runtime ever ran. `examples/form/` is the example of the fix:
502
+ AppKit names, icons and identifies an app by the **bundle its executable
503
+ lives in**. Run from the shell, the menu bar says "node", the Dock shows the
504
+ generic icon, the defaults domain (window frames, the tab bar) is shared with
505
+ every script that runtime ever ran, and the notification centre refuses the
506
+ app. `cocoa: { appName }` fixes the name alone; a bundle fixes all of it.
507
+ `examples/form/` is the example:
2212
508
 
2213
- ```
509
+ ```sh
2214
510
  npm run examples:form:app # builds examples/form/build/Guestbook.app and opens it
2215
511
  ```
2216
512
 
2217
- [`make-app.sh`](../examples/form/make-app.sh) copies `Info.plist`, renders
2218
- `icon.svg` into an `.icns` with the system's own tools (Quick Look, `sips`,
2219
- `iconutil`), and makes the executable with `bun build --compile`: one Mach-O
2220
- with bun's runtime, the example and react-x11 inside it, at
2221
- `Contents/MacOS/Guestbook`. AppKit takes an app's main bundle from the
2222
- running executable's path, so a real binary there is the whole trick — no
2223
- launcher, nothing to find at launch time.
2224
-
2225
- What a compiled binary cannot contain is the Cocoa bridge's native addon,
2226
- which the backend loads with `require` from a file. The bundle carries it
2227
- as `Contents/Resources/calayers.node`, and the compile entry,
2228
- [`main.js`](../examples/form/main.js), names it through the backend's
2229
- `REACT_X11_CALAYERS_PATH` seam — relative to `process.execPath`, so the
2230
- bundle can be moved — before importing the example. `Info.plist` carries
2231
- the name, the identifier (`com.example.guestbook`), the icon,
2232
- `NSHighResolutionCapable` (without it AppKit renders at 1×) and
2233
- `LSEnvironment` naming the Cocoa backend. Launch Services then reports the
2234
- process as the app:
2235
-
2236
- ```
2237
- $ lsappinfo info -only name,bundleid,bundlepath "$(lsappinfo find pid=<pid>)"
2238
- "LSDisplayName"="Guestbook"
2239
- "CFBundleIdentifier"="com.example.guestbook"
513
+ [`make-app.sh`](../examples/form/make-app.sh) makes the executable with
514
+ `bun build --compile` at `Contents/MacOS/Guestbook`: AppKit takes the main
515
+ bundle from the running executable's path, so it must be a real binary, not
516
+ a script.
517
+
518
+ - **The bridge's addon cannot be compiled in.** The bundle carries it as
519
+ `Contents/Resources/calayers.node`, and the entry,
520
+ [`main.js`](../examples/form/main.js), points `REACT_X11_CALAYERS_PATH` at
521
+ it relative to `process.execPath` before importing the app, so the bundle
522
+ can be moved.
523
+ - **`Info.plist`** carries the name, the identifier, the icon,
524
+ `NSHighResolutionCapable` (without it AppKit renders at 1×) and
525
+ `LSEnvironment` naming the Cocoa backend. `lsappinfo` then reports the
526
+ process under the app's name and identifier.
527
+
528
+ **The tab bar under bun.** Unbundled, a `bun` app's windows live in the
529
+ `bun` defaults domain, and once anything turns the tab bar on there (View →
530
+ Show Tab Bar, a stray shortcut) every window the bridge opens wears a 28pt
531
+ tab bar and every popup lands that much off. Clear it, or run as a bundle:
532
+
533
+ ```sh
534
+ defaults delete bun "NSWindowTabbingShoudShowTabBarKey-NSWindow-CALBackendDelegate-(null)-VT-FS"
2240
535
  ```
2241
536
 
2242
- (A script can be a bundle's executable too, and there is a way to make
2243
- AppKit take the bundle rather than the interpreter as the main one —
2244
- `#!/usr/bin/env -S CFProcessPath=<this file> bun` sets CoreFoundation's
2245
- process path before the interpreter starts — but the compiled binary needs
2246
- no such trick, and nothing installed on the machine at launch time.)
2247
-
2248
537
  ### Distributing the bundle
2249
538
 
2250
- What `make-app.sh` builds is a developer's bundle: it runs where it was
2251
- built, and nowhere else without three more steps.
2252
-
2253
- - **Signing.** The binary is unsigned. Gatekeeper lets an unsigned app run
2254
- from a build directory, but not one that arrived in a download (the
2255
- quarantine attribute) — for that it wants a Developer ID signature with
2256
- the hardened runtime, then notarization. Sign **inside-out, and not
2257
- with `--deep`**: Apple DTS says not to use it ("Signing a Mac Product
2258
- For Distribution" and "Creating distribution-signed code for Mac",
2259
- forums threads 128166 and 701514) and to sign nested code first, with
2260
- entitlements on the main executable only. So: the addon in `Resources`,
2261
- without entitlements, then the app with them:
2262
-
2263
- ```sh
2264
- ID="Developer ID Application: NAME (TEAMID)"
2265
- codesign --force --timestamp --sign "$ID" Guestbook.app/Contents/Resources/calayers.node
2266
- codesign --force --timestamp --options runtime --entitlements bun.plist \
2267
- --sign "$ID" Guestbook.app
2268
- ditto -c -k --keepParent Guestbook.app Guestbook.zip
2269
- xcrun notarytool submit Guestbook.zip --keychain-profile notary --wait
2270
- xcrun stapler staple Guestbook.app
2271
- ```
2272
-
2273
- `bun.plist` is the five entitlements from Bun's "Code signing on macOS"
2274
- (`allow-jit`, `allow-unsigned-executable-memory`,
2275
- `disable-executable-page-protection`, `allow-dyld-environment-variables`,
2276
- `disable-library-validation`). For a copy handed to a colleague, the
2277
- same two `codesign` lines with `--sign -` and
2278
- `xattr -dr com.apple.quarantine` on the receiving end are the short
2279
- path. The machine this was written on has no signing identity: the
2280
- ad-hoc steps were run, the Developer ID ones are Apple's documentation —
2281
- [packaging.md](packaging.md#developer-id-or-the-app-store) keeps the
2282
- line between the two.
2283
-
2284
- - **The Mac App Store is a different bundle.** The store requires App
2285
- Sandbox, and a `bun build --compile` binary does not start under it.
2286
- Measured 2026-09-08 (macOS 15.2, bun 1.4.0): this bundle re-signed with
2287
- only `com.apple.security.app-sandbox` dies every time — `SIGTRAP` in
2288
- `_libsecinit_appsandbox` for the bare binary, `SIGABRT` in HIServices'
2289
- `_RegisterApplication` inside the bundle, after the kernel logs
2290
- `Sandbox: Guestbook deny(1) mach-lookup com.apple.coreservices.launchservicesd`
2291
- (oven-sh/bun#15661, open). The same example as a node single executable
2292
- (`node --build-sea`, node 26) in this bundle's shape — same plist, same
2293
- `calayers.node` in `Resources` — runs sandboxed, launched by `open`,
2294
- registered with Launch Services, in its own container, with no sandbox
2295
- denial logged. So a store build is packaging.md's tier 3 inside its
2296
- tier 5, signed with "Apple Distribution", packaged with `productbuild`
2297
- and uploaded with Transporter; the recipe, the two esbuild defines the
2298
- form example needs today, and what was and was not run are in
2299
- [packaging.md](packaging.md#developer-id-or-the-app-store). Bun stays
2300
- fine for Developer ID, which needs no sandbox.
2301
- - **Architecture.** `bun build --compile` targets one CPU and the bridge's
2302
- prebuilds are one per CPU; the script builds for the machine it runs on.
2303
- A universal bundle is two builds and `lipo` — for the executable and for
2304
- `calayers.node` alike.
2305
- - **Paths.** The compiled binary is self-contained, and `main.js` finds the
2306
- addon relative to `process.execPath`, so the bundle can be moved, zipped
2307
- and dragged into `/Applications`. Nothing in it refers to the repo.
2308
- - **Size.** About 70MB, nearly all of it bun's runtime; the example and
2309
- react-x11 are a few hundred kilobytes of it. The node SEA of the same
2310
- example, the store shape, is 142MB.
2311
-
2312
- **The tab bar under bun.** Without a bundle, a window opened by `bun` is in
2313
- the `bun` defaults domain, and once anything has turned on the tab bar for
2314
- one of our windows there — View → Show Tab Bar, or a stray shortcut — AppKit
2315
- persists it as
2316
- `NSWindowTabbingShoudShowTabBarKey-NSWindow-CALBackendDelegate-(null)-VT-FS`
2317
- and every window the bridge opens from then on wears a 28pt tab bar. It also
2318
- shifts the content view down by that much, which the bridge's
2319
- `getWindowFrame` does not report (windowkit/appkit#12), so every popup lands
2320
- a bar's height off. Clear it with
2321
-
2322
- ```
2323
- defaults delete bun "NSWindowTabbingShoudShowTabBarKey-NSWindow-CALBackendDelegate-(null)-VT-FS"
2324
- ```
2325
-
2326
- or run the app as a bundle, whose own domain has never seen the key. The
2327
- durable fix is the bridge's: `tabbingMode = NSWindowTabbingModeDisallowed`
2328
- on every window it opens.
2329
-
2330
- ## Public API: what changes
2331
-
2332
- Measured against both consumers, the surface holds; the deltas are
2333
- additive or mechanical:
2334
-
2335
- 1. **Backend selection.** `createRoot()` gains
2336
- `backend: 'x11' | 'cocoa' | 'auto'` (default `'auto'`: Cocoa on
2337
- darwin when the bridge is installed, else X11 via `$DISPLAY`;
2338
- `REACT_X11_BACKEND` overrides for A/B). Backend-specific options
2339
- stay flat but documented per backend (`display`/`stream`/`glxVisual`/
2340
- `glPolicy` are X11's; an unknown-to-this-backend option throws with
2341
- the usual corrective error). `createRoot({ app })` keeps working and
2342
- is how tests inject mocks of either flavor. _As built, the set is
2343
- `'x11' | 'cocoa' | 'wayland' | 'win32' | 'auto'`: `'auto'` takes
2344
- win32 on Windows the way it takes Cocoa on darwin, and never chooses
2345
- Wayland (`resolveBackend`, src/Reconciler.js)._
2346
- 2. **Capabilities.** `useSupports` grows `'nativeControls'`,
2347
- `'embedding'` (false here), `'globalMenu'`, `'transforms'` (when built)
2348
- beside `'transparency'` and `'shaders'`; same store-and-settle semantics.
2349
- 3. **Inert props policy** (wayland.md open question 1, now decided the
2350
- way it leaned): same JSX everywhere; a prop with no meaning on this
2351
- backend (`appId`, `xi2`, `onClientMessage`, X-only states) is
2352
- inert with a one-time dev-mode note naming the backend — not a
2353
- throw, so shared app code stays branch-free.
2354
- 4. **Keysyms are the key vocabulary**, formally: documented as
2355
- react-x11's own symbolic key codes (X heritage acknowledged), with
2356
- the Cocoa input layer synthesizing them. No consumer changes.
2357
- 5. **Chord grammar** gains `Primary` (§Menus). Existing tokens keep
2358
- their literal meanings.
2359
- 6. **Test harness**: `renderX11(..., { backend })` namespace gains
2360
- `'cocoa'` (real, windowed) and `'cocoa-mock'` (headless presenter
2361
- recorder); existing names untouched. `toPNG`/`pixelAt` on Cocoa read
2362
- the window snapshot. Pixel _hashes_ are per-backend by construction
2363
- (CoreText raster ≠ ntk raster); cross-backend assertions compare
2364
- structure and behavior, not bytes.
2365
- 7. **Packaging.** The bridge publishes as its own package — a
2366
- freestanding, toolkit-agnostic Cocoa bridge (its README already
2367
- documents the host-config mapping for any reconciler), `os:
2368
- ["darwin"]`, prebuilds for arm64/x64, N-API (which keeps the Bun
2369
- door open; verify in Phase 1). react-x11 references it as an
2370
- `optionalDependency` exactly as it does `dbus-native`: absent on
2371
- Linux installs, absent even on macOS if the user skips optional
2372
- deps — and `backend: 'auto'` then falls back to X11 with a
2373
- diagnostic. The Cocoa backend code itself lives in-repo
2374
- (`src/cocoa/…`), not a separate package: one repo, one test suite,
2375
- one release train, the same reasoning wayland.md's open question 2
2376
- leaned to.
2377
- 8. **The name.** Nothing in this design _requires_ renaming `react-x11`,
2378
- and both consumers import it by that name today. But this RFC is the
2379
- moment the name becomes descriptive of one backend out of three
2380
- planned, and pre-release is the last cheap moment to change it. The
2381
- options are (a) keep it (Electron ships Chromium nobody renamed;
2382
- the name becomes heritage), or (b) rename once, now, to something
2383
- backend-neutral, with `react-x11` republished as an alias or left as
2384
- the X11-flavored entry. **This document deliberately does not decide
2385
- it** — it is a taste call for the maintainer — but it does say:
2386
- decide before the Cocoa backend ships, because every README, example
2387
- and import written after that multiplies the cost, and do not ship a
2388
- compatibility alias _afterward_ (AGENTS.md's own shim rule).
2389
-
2390
- ## The bridge: the required API
2391
-
2392
- The contract that was published against, grouped, with the POC's coverage
2393
- marked at the time (✅ had it, 🆕 was the work). Mechanism only; policy
2394
- stays in the renderer. `@windowkit/appkit` has since grown past this list —
2395
- its own README is the current reference; this is the shape it was asked
2396
- for.
2397
-
2398
- **App & run loop.** `initApp()`; `pump()` ✅; run-loop drain hook
2399
- (option 2: install/remove the libuv drain, with the re-entrancy guard)
2400
- 🆕; display link per window (`onFrame(cb)` with timestamps, start/stop)
2401
- 🆕; activation policy + `activate()`, app/dock name 🆕; termination and
2402
- `applicationShouldTerminate` routing 🆕; open-URL/open-file Apple Event
2403
- callbacks (for `onAppOpen`) 🆕.
2404
-
2405
- **Windows.** create ✅ with style options (titled/borderless/panel,
2406
- non-activating, level, opaque/clear) 🆕partial; close ✅; title, frame
2407
- get/set in top-left global coords 🆕 (size ✅); min/max size, aspect 🆕;
2408
- zoom/miniaturize/fullscreen + state readback 🆕; `addChildWindow` 🆕;
2409
- delegate events — resized (live-resize ticks included), moved,
2410
- close-requested, key/main changed, backing-scale changed, screen
2411
- changed, miniaturized 🆕; `scale` ✅; snapshot ✅; per-window event
2412
- routing (window id on every event) 🆕; `requestUserAttention`,
2413
- `orderFront/out` 🆕.
2414
-
2415
- **Layers.** create Layer/Text/Gradient/Shape ✅; `set(props)` for
2416
- frame/bounds/position/anchor, backgroundColor, cornerRadius (+
2417
- `cornerCurve`, `maskedCorners`), border, shadow (+ **`shadowPath`** 🆕),
2418
- opacity, zPosition, transform, `masksToBounds`, mask, `contentsScale`,
2419
- `contentsCenter`/`contentsRect`/`contentsGravity` 🆕partial;
2420
- `addSublayer` ✅ + **`insertSublayer(at/below/above)`** 🆕; remove ✅;
2421
- `bounds.origin` scroll offset (covered by `set`) ✅; **raw-buffer
2422
- contents upload** (premultiplied BGRA/RGBA + stride → layer contents,
2423
- ideally IOSurface-backed for no-copy) 🆕; image contents ✅; **batched
2424
- ops** — one call applying a serialized list of (layer, props) mutations
2425
- inside one transaction, so a big commit is one NAPI crossing 🆕;
2426
- transactions ✅ (`begin/commit`, duration, timing, `disableActions`,
2427
- completion callback 🆕); explicit animations ✅ (+ removal ✅,
2428
- `animationDidStop` callback 🆕).
2429
-
2430
- **Drawing (raster fallback).** A CG bitmap surface object: create at
2431
- size×scale ✅, a canvas2d-compatible verb set over it ✅ (native-side,
2432
- one call per verb — CG covers the standard set; `fillRects` →
2433
- `CGContextFillRects` ✅; `drawGlyphs` → `CTFontDrawGlyphs` ✅), read and
2434
- write pixels ✅, hand to a layer as contents without a copy ✅
2435
- (`createSurfaceIOSurface` + `setLayerContentsIOSurface`), draw one
2436
- surface into another ✅ (`ctxDrawSurface`), shift a band in place ✅
2437
- (`scrollSurface`), copy rects between same-size surfaces ✅
2438
- (`copySurfaceRegion`), the blend mode a `globalCompositeOperation` sets ✅
2439
- (`ctxSetBlendMode`, 0.7.0) and a row memcpy between surfaces of any two
2440
- sizes ✅ (`blitSurface`, 0.7.0 — issue #498; the context feature-detects
2441
- both, so an older bridge draws every composite as a CGImage under
2442
- source-over), and the resampling an `imageSmoothingQuality` sets ✅
2443
- (`ctxSetImageSmoothing`, 0.23.0), and a radial gradient ✅
2444
- (`ctxFillRadialGradient`, 0.28.0). Still 🆕: a pattern fill (`createPattern`,
2445
- which `<Flow>`'s grid tiles ask for), and an explicit
2446
- free (a surface goes with its handle's finalizer). The alternative — rasterize JS-side and use the raw-buffer
2447
- upload — stays open; the API supports both so the choice can be
2448
- measured.
2449
-
2450
- **Text.** measure ✅ → full layout object 🆕: build from span list
2451
- (attributed string), report lines/runs/origins, `indexAt`,
2452
- `caretOffset`, range rects, truncation/`maxLines`, draw-to-bitmap ✅ and
2453
- draw-into-surface 🆕, glyph-run access for `drawGlyphs` ✅
2454
- (`fontGlyphForCodepoint`, `fontGlyphAdvances`, `fontFallbackFor`,
2455
- `ctxDrawGlyphs` in 0.2.0, `fontShapeText` and `fontWithSize` in 0.3.0 —
2456
- windowkit/appkit#1);
2457
- font matching by family list/weight/style/variations → descriptor, and
2458
- loading app-supplied font files (`loadFont`) 🆕; `hasGlyph` ✅.
2459
-
2460
- **Controls.** `controls.render` ✅ (push/default/checkbox/radio/popup/
2461
- slider/switch, states, sizes, appearance) — add: focus-ring state,
2462
- progress bars (bar/spinner), disclosure, natural-metrics query,
2463
- nine-slice insets per kind 🆕; `controls.isDark` ✅ → replaced by
2464
- appearance API below.
2465
-
2466
- **Menus.** Build/patch NSMenu trees from an item list (title, enabled,
2467
- hidden, state/mixed, keyEquivalent+mask, separator, submenu, image?);
2468
- stable item handles for in-place patching; set as main menu; standard
2469
- app/Edit/Window skeleton helpers; context popup at screen point;
2470
- `onAction(id)`; `menuWillOpen` hook 🆕 (all new).
2471
-
2472
- **Pasteboard.** read/write types (string, RTF?, file URLs, custom UTIs
2473
- mapped from MIME), change count polling, lazy provider callback 🆕.
2474
-
2475
- **Drag & drop.** Begin drag session (image, items); destination
2476
- registration on the view with enter/over/exit/drop callbacks carrying
2477
- position + types 🆕.
2478
-
2479
- **Screens & appearance.** `NSScreen` list (frame, visibleFrame, scale,
2480
- id) + change notification; `effectiveAppearance` (dark/light, high
2481
- contrast, reduce motion, accent + change notifications) 🆕
2482
- (replaces `appearanceIsDark` ✅).
2483
-
2484
- **Cursors & misc.** Standard cursor set + hide/show (`setCursor`
2485
- parity) 🆕; `NSStatusItem` (tray; later) 🆕; open/save panels (later)
2486
- 🆕; a11y virtual element tree (later, its own design) 🆕.
2487
-
2488
- **Testing hooks.** `postMouseEvent` ✅ extended with modifiers, wheel,
2489
- key events 🆕; window snapshot ✅; a headless mode statement (CI:
2490
- windowed Aqua sessions on macOS runners are expected to work — verify
2491
- in Phase 1; the mock presenter keeps unit tests off the GUI entirely).
2492
-
2493
- ## The split: what the bridge owns, what the renderer owns
2494
-
2495
- Written 2026-09-03 against react-x11 2.4.0 and `@windowkit/appkit`
2496
- 0.3.0, when the question came up in the concrete form "if the bridge
2497
- grew a full canvas2d API, could it replace ntk on this backend
2498
- outright?" — with the retained tier's need to reach layers, and two
2499
- more backends on the horizon (Wayland at the canvas2d level, Windows
2500
- with primitives not yet chosen), to be held in the same design. The
2501
- answer is a division of labour, and it is mostly the one the code
2502
- already has.
2503
-
2504
- **What is where today.** The bridge exports 36 `ctx*` verbs over an
2505
- opaque surface handle, 8 surface operations (create, IOSurface-backed
2506
- create and lookup, lock/unlock, size, copy rects between two surfaces,
2507
- scroll within one, hand to a layer), the CoreText natives, the layer
2508
- and transaction natives, windows, pasteboard, menu, controls, screens
2509
- and events — one N-API crossing per verb, no state, no policy. On top
2510
- of it `src/cocoa/` is ~4,500 lines: the canvas-shaped context (the
2511
- JS-visible state and the ntk dialect over the verbs), the text engine,
2512
- the layer presenter, the window with its IOSurface swapchain, the app.
2513
- What the cocoa path reaches ntk for is pure JS — colour parsing, the
2514
- shadow math, `SvgView`'s traversal, the image decoders, fontkit metrics
2515
- behind `openFont` — and no X object is constructed. So "replace ntk"
2516
- is already true in substance: on macOS ntk is a utility library and
2517
- the X implementation of the drawing dialect. The one ntk object a
2518
- consumer still handed the backend was `Surface`, and the fix for that
2519
- is dispatch (issue #433, `react-x11/ntk` asking the app), not a port.
2520
-
2521
- **The dialect is the floor contract, and it lives here.** Every backend
2522
- implements canvas2d plus the ntk extensions — `fillRects`, `drawGlyphs`
2523
- with its run contract, `positioned`, Path2D as an argument to
2524
- fill/stroke/clip, the callback-shaped `getImageData`, `Render.PictOp`
2525
- numbering. That is react-x11's policy: it is what `src/nodes/` and every
2526
- registered element paint against, X11 answers it through ntk's XRender
2527
- encoder, Cocoa through `CocoaContext2D` over CG verbs, Wayland will
2528
- through the `'2d-sw'` rasterizer [wayland.md](wayland.md) builds on
2529
- X11 first. The JS class stays in this repository rather than moving
2530
- into the bridge for three reasons. It is the dialect, and a dialect
2531
- defined in three repositories drifts three ways — every context fix
2532
- would become a floor bump, the way #434 cost a `^0.3.0` floor and a
2533
- lock regeneration for two natives. A wrapper that forwards to a **verb
2534
- table** is reusable by any addon that speaks the same verbs, so a
2535
- Windows addon over a Direct2D render target plugs in with no wrapper
2536
- changes at all, where a class inside `@windowkit/appkit` could never be
2537
- reused by Windows or Wayland. And the fake-bridge tests already pin it
2538
- here. The bridge's contribution is exactly the verb table — mechanism —
2539
- and that table is the native-addon contract to standardise:
2540
- `createSurface`/`surfaceSize`/`scrollSurface`/`copySurfaceRegion`/
2541
- `ctxDrawSurface`/`ctxGetImageData`/`ctxPutImageData` and the `ctx*`
2542
- verbs over an opaque handle. Cocoa's is `@windowkit/appkit`'s export
2543
- list as it stands.
2544
-
2545
- **Layers are a ceiling, behind the seam that exists.** The bridge
2546
- speaks two vocabularies, raster (surfaces, verbs, text) and retained
2547
- (layers, transactions, shape and gradient layers, contents), and the
2548
- renderer chooses per window; the node model never learns which. The
2549
- retained presenter needs the raster vocabulary too — its Raster visual
2550
- replays a node's paint into a bitmap and hands the bitmap to a layer —
2551
- so canvas2d is what every backend must provide and layers are what a
2552
- backend may add, behind `noteInvalidate`, `presentFrame` and a null
2553
- `scrollRegion`: three feature-detected hooks, the X11 path
2554
- byte-identical. That is the whole integration surface, and it should
2555
- stay that size. The presenter does not move into the bridge (it
2556
- consumes the node model, which is policy), and there is no third,
2557
- hybrid mode: a node is either properties on a layer or a raster on
2558
- one, decided per node per frame.
2559
-
2560
- **The channel is already node-granular.** `invalidate(layoutChanged,
2561
- node | null, reason)` reaches the presenter as `noteInvalidate(damage,
2562
- layoutChanged, reason)` — which node, what kind — and the X11 painter
2563
- is the one reducing it to rects. The layer presenter today collapses a
2564
- structural `null` into a walk of the whole tree, which is the measured
2565
- ~40 ms per frame at ~1,500 nodes (the presenter bench's largest open
2566
- number). That is a presenter-internal fix — dirty-subtree pruning off
2567
- yoga's per-node layout flags and the `_reflowed` set, the same signals
2568
- the X11 path uses to keep layout dirt local — not a new channel. The
2569
- `reason` vocabulary already separates `'scroll'`, which is the
2570
- `bounds.origin` case.
2571
-
2572
- **What is genuinely shared across the coming backends** is the
2573
- client-owned bitmap with buffered presentation. The window's swapchain
2574
- (`_ensureSurface`/`_takeBack`/`present`/`scrollRegion`/`noteFrameDamage`)
2575
- is written over six primitives — create, lock/unlock, copy region between
2576
- two surfaces, scroll within one, present, and whether the compositor still
2577
- holds a buffer — and a Wayland `wl_buffer` pool, where the compositor
2578
- holds a buffer until `release`, is the same shape (catch-up copy or
2579
- buffer-age damage, [wayland.md](wayland.md)); so is a Windows swap chain
2580
- or DIB pair. The offscreen `Surface` is the same family: written over
2581
- `createSurface`, `surfaceSize`, `scrollSurface` and `ctxDrawSurface` plus
2582
- the wrapper, behind the app seam (`app.createSurface(options)`), so a
2583
- Wayland app implements the seam over a CPU bitmap with its `'2d-sw'`
2584
- context and `copyWithin` is the memmove `scrollSurface` already is. Per
2585
- backend, then: Wayland's context is the JS rasterizer, so the dialect
2586
- holds by construction, and with no retained tier (`wl_subsurface` is too
2587
- coarse) `presentFrame` is absent and the X11 paint path runs verbatim over
2588
- the swapchain; Windows' two candidate primitive sets are exactly the two
2589
- vocabularies — a Direct2D target for the verb table, DirectComposition
2590
- visuals for a presenter — so it starts at the floor with the wrapper and
2591
- the swapchain reused and adds a composition presenter later behind the
2592
- same seam. [windows.md](windows.md) is that backend's PRD.
2593
-
2594
- **In order, with the priority stated:** the existing two backends, X11
2595
- and Cocoa, become stable and performant before a third is started, so
2596
- the steps that serve them come first. (1) This section: the verb table
2597
- and the surface operations are the native-addon contract. (2) Issue
2598
- #433: `CocoaSurface` over that contract, the `createSurface` seam,
2599
- dispatch in `react-x11/ntk` — landed with this text. (3) The
2600
- presenter's dirty-subtree pruning, ahead of anything below because it
2601
- is the open number on a shipped backend. (4) Moving the context
2602
- wrapper and the swapchain under `src/backend/` with neutral names — when
2603
- a second native backend or Wayland actually starts, and not before: a
2604
- half-renamed tree is worse than either name. (5) Bridge growth stays
2605
- verb-shaped — a pattern fill for `<Flow>`'s grid tiles, a blend op for
2606
- `PictOp.Src` — and never a JS canvas class.
2607
-
2608
- _As built: Wayland did not take the `'2d-sw'` route. Its context is a GLES
2609
- rasterizer of its own (`src/wayland/context2d.js`, wayland.md's Tier D),
2610
- which draws the dialect rather than forwarding it. Step (4) was taken for
2611
- the context alone when the Windows backend started — `src/backend/context2d.js`
2612
- drives both bridges' verb tables — and the swapchain stayed in `src/cocoa/`._
2613
-
2614
- ## Measured: the frame clock and the large tree
2615
-
2616
- Written 2026-09-03 against react-x11 2.5.0 and `@windowkit/appkit` 0.3.0,
2617
- on an M1 Pro at 2x, from the stress scenarios `npm run bench:presenters`
2618
- grew for it — "state update → pixels" and "interaction → pixels" inside a
2619
- tree the size of an application screen (`--cells`, 1,200 labelled boxes:
2620
- 3,662 nodes) — and the surface presenter, which is the default. The
2621
- numbers travel as well as any timing does; the ratios are the findings.
2622
-
2623
- **What a frame cost, and what it costs now** (median flush unless said
2624
- otherwise; `cpu` is the process as a share of one core, driver ticks at
2625
- 62.5Hz):
2626
-
2627
- | scenario | before | after |
2628
- | ----------------------------------------- | -------------------------- | -------------------------- |
2629
- | `tree` — one memoized cell per tick | 1.25ms | 0.35ms |
2630
- | `tiny` — one of 5,000 cells (14k nodes) | 7.2ms p95 | 1.2ms p95 |
2631
- | `multi` — 12 scattered cells per tick | 11.9ms, 1,470kpx repainted | 2.8ms, 264kpx |
2632
- | `press` — down/up, input → flush | 4.3ms p50 / 10.2 p95 | 2.6ms p50 / 8.7 p95 |
2633
- | `scroll` — wheel notch, input → flush | 15.2ms p50 | 1.9ms p50 |
2634
- | `resize` — a live tick, 40-tick drag | 71ms, 2 full frames each | 28ms, 1 frame + 1 catch-up |
2635
- | `occluded` — hidden window, cell per tick | 207 frames, 50% cpu | 0 frames |
2636
- | `layout` — every cell reflows per tick | 44ms (18fps) | 44ms — see below |
2637
-
2638
- Five changes, each fenced by a test:
2639
-
2640
- - **The paint reach is cached** (`Node._paintBoundsCache`, src/nodes/invalidate.js). A
2641
- bounded frame asked every subtree on the way to its rect whether it
2642
- reached in, and each answer walked the subtree: a one-cell repaint cost
2643
- the whole tree, linearly. The union is now kept until something that
2644
- moves it announces itself — layout, a child list change, a style swap, a
2645
- state flip, `invalidate` — with `REACT_X11_NO_BOUNDS_CACHE=1` as first
2646
- aid. Shared with X11, and the protocol bench is unchanged by it:
2647
- `test/paint-reach.test.js`.
2648
- - **The damage rect cap is the backend's** (`window.damageRectCap`). Four
2649
- rects is what a pass costs the X server; a Cocoa pass is one CoreGraphics
2650
- clip and a culled walk, so its window keeps sixteen — twelve components
2651
- ticking at once paint their twelve rects instead of the box around them.
2652
- - **A resize tick is one frame** (`CocoaWindow._freshSurface`,
2653
- `_routeGeometry`). Replacing the backing surface used to queue a second
2654
- full frame behind the one already painting it, and the React-half flush
2655
- was queued per event — and inside AppKit's resize loop no microtask runs
2656
- until the drag ends, so a forty-tick drag ran eighty full frames on the
2657
- mouse release: the freeze after a resize. A fresh surface now asks for a
2658
- frame only when the flush that found it was bounded, and holds the
2659
- present until that frame lands; the release owes at most one flush.
2660
- `test/cocoa-frames.test.js`.
2661
- - **A live resize defers the content floors** (`_deferContentFloors`,
2662
- src/nodes/window/size.js). The floors (#249) were three extra layout passes and their
2663
- walks — 21 of the 44ms a relayout cost on this tree, before they were
2664
- measured incrementally (§"Measured: incremental floors") — and a drag
2665
- calls the frame from inside every pointer move. A live tick lays out against
2666
- the floors it has (exact along the main axis, a frame stale for wrapped
2667
- text across it) and the first frame tick after the release measures once
2668
- and lays out again: answer the input, then catch up. Only under
2669
- `liveResizing`, which the Cocoa window holds between AppKit's begin and
2670
- end of the drag (`window-live-resize`); an X window never sets it. A content change mid-drag takes
2671
- the measured path.
2672
- - **A window nobody can see owes nothing** (`CocoaWindow._visible`): frames
2673
- for a window that is ordered out or miniaturized wait in the queue and
2674
- its present waits with them; one catch-up frame when it is back. (A
2675
- window entirely behind another application's window joined that rule in
2676
- the pass after this one — see below.) The frame gate also moved from
2677
- "one interval since the last frame" to
2678
- "the first pump tick at or after it", which took a 16/24/16/24ms cadence
2679
- to a steady 16 (52 → 56fps against the 62.5Hz driver), and the wheel is
2680
- answered on the event like a press — AppKit already delivers scroll
2681
- events at the display's rate, so that is once per refresh by
2682
- construction. (Mostly: a trackpad lands two in one tick often enough
2683
- that the window now gates it — see "The wheel is gated to the display too" below.)
2684
-
2685
- The structural half of these numbers is a gate now:
2686
- `npm run bench:presenters -- --check` judges full-window frames, the share
2687
- of the window a cell repaints, frames per resize tick and frames for a
2688
- hidden window against `scripts/bench/presenters-gate.json`, and
2689
- `npm run bench:touched` runs it — with the X11 gates — only for a change
2690
- that reaches a hot path. CI's `bench-cocoa` job does the same on a macOS
2691
- runner.
2692
-
2693
- **What was left, in order of what it cost** — the list that became #442,
2694
- and the pass below.
2695
-
2696
- ## Measured: after the frame-clock pass
2697
-
2698
- Written 2026-09-03 against react-x11 2.5.0 and `@windowkit/appkit` 0.4.0
2699
- (windowkit/appkit#10, which shipped the bridge half of #442: `releaseSurface`,
2700
- every surface's bytes accounted to V8, `fps` on `listScreens`, and
2701
- `window-occlusion` events), same machine, same bench, the surface presenter.
2702
- Four of the six items are closed by it; the two that remain are the
2703
- renderer's own and are listed at the end.
2704
-
2705
- | what | before | after |
2706
- | ------------------------------------------------------------- | ----------------------- | --------------------- |
2707
- | a 40-tick resize burst, no yield between ticks — rss during | +572 to +655MB | +12 to +14MB |
2708
- | `anim` on the 120Hz panel | 55.7fps, 32% cpu (16ms) | 113.5fps, 49% (8.3ms) |
2709
- | `hover` on the 120Hz panel, input → flush p50 / p95 | 7.2ms / 16.8 | 3.1 / 9.2 |
2710
- | `icons` — 300 mono icons re-tinted per tick, flush avg / p95 | 2.98ms / 3.35, 72% cpu | 2.07 / 2.42, 67% |
2711
- | `covered` — the big tree behind another window, cell per tick | a frame per tick | 0 frames |
2712
-
2713
- - **The retired pair is released on the flip** (`CocoaWindow._releaseBacking`,
2714
- `releaseSurface`). A resize tick allocates two window-sized IOSurfaces and
2715
- used to leave the two it replaced to their handles' finalizers — which run
2716
- on the event loop, and inside AppKit's resize loop the event loop does not
2717
- run, so a drag held every pair it ever made until the release. The bridge's
2718
- accounting alone (`napi_adjust_external_memory`) makes the collection
2719
- prompt once the loop is back; the explicit free is what bounds the peak
2720
- while it is not, which is the number in the table. The row is a script
2721
- driving forty `setWindowFrame`s synchronously, the way the modal loop
2722
- delivers them; the bench's `resize` cell yields between ticks and its rss
2723
- column is collection noise either way now. `CocoaSurface.destroy()` and
2724
- the layer presenter's rasters release the same way, and a bridge without
2725
- the verb gets the drop it always had. `test/cocoa-frames.test.js`,
2726
- `test/cocoa-surface.test.js`.
2727
- - **Each window paces itself on its own display** (`CocoaApp.frameIntervalFor`,
2728
- `_frameDue`). The default interval is `1000 / fps` of the screen under the
2729
- window's centre, re-read when it moves; `createRoot({ cocoa: { frameInterval
2730
- } })` still overrides it for every window. The frame queue keeps a clock per
2731
- window, so a window on the 120Hz panel paints every refresh while one on a
2732
- 60Hz monitor paints every other pump tick. What it is worth is the `anim`
2733
- and `hover` rows: twice the frames for half again the CPU, and input →
2734
- flush halved, on the panel that can show it. One honest limit at the
2735
- time: the 8ms pump quantized the cadence, so a 75Hz monitor (13.3ms)
2736
- painted at 62.5fps — the gate was the interval less half a pump, and
2737
- 9.3ms fell on the 16ms tick. The pass below gives such a frame a timer
2738
- of its own.
2739
- - **A window entirely behind another application's window owes nothing**
2740
- (`CocoaApp._routeOcclusion`, `CocoaWindow._visible`). AppKit's
2741
- `windowDidChangeOcclusionState` arrives as `window-occlusion`; off, the
2742
- window's frames wait in the queue and its present with them, exactly as an
2743
- ordered-out window's do; on, the next pump tick runs one catch-up frame.
2744
- `map()` resets the flag, since a show is a claim to be on glass and AppKit
2745
- corrects it a pump later. The bench's new `covered` scenario opens a window
2746
- of our own over the tree and is in the gate at zero frames. What is not
2747
- free while covered is the React commit itself: the scenario's 41% CPU is
2748
- `root.render` of a 3,662-node tree per tick, and no frame clock can take
2749
- that.
2750
- - **The paint cache is on** (`paintCacheFor` accepts an app with
2751
- `createSurface`; `PaintCache.coverage`). Entries are `CocoaSurface`s
2752
- through `react-x11/ntk`'s `Surface`, composited with one `ctxDrawSurface`.
2753
- This backend has no coverage surface, so a mono drawing is cached as
2754
- argb32 with its colour in the key — `paintCached(ctx, box, ink)` now says
2755
- which colour to paint in — one entry per colour rather than one for all of
2756
- them, still one render per colour instead of one per cell per frame. A
2757
- blurred shadow, whose blur is a pass over the mask, stays live. The `icons`
2758
- row is the gain, and the larger cost left in that scenario is the React
2759
- re-render of 300 unmemoized components. `test/cocoa-paint-cache.test.js`.
2760
- - **A pane's frame gate is real** (`CocoaPaneWindow.frameInFlight`). The
2761
- early flush a discrete input gets (`flushPendingFrames`, src/frames.js) is
2762
- safe because it is gated on the last frame having landed — that gate is
2763
- what folds a burst into one paced frame — and the pane window answered it
2764
- with a constant `false`. So every message the host had queued while the
2765
- pane was busy painting got a full frame of its own: a forty-tick resize of
2766
- `examples/frame.jsx`'s pane, whose frame costs 250–320ms, stepped through
2767
- 42 presents at 42 intermediate sizes and reached the final one twelve
2768
- seconds after the drag had ended, each step the previous IOSurface
2769
- stretched to the layer's new frame. A pane hears nothing back from the
2770
- host, so a present now counts as in flight for one frame interval; the
2771
- same burst is one frame at the size it ended on, plus the one the first
2772
- message was answered with. `test/cocoa-frames.test.js`. Since #870 the
2773
- host answers each present, and the gate is that answer as well
2774
- (§"Measured: a pane's presents, answered").
2775
- - **The wheel is gated to the display too** (`CocoaApp._routeWheel`,
2776
- `CocoaWindow._flippedRecently`). The wheel's early flush painted and
2777
- flipped for every scroll event, and a trackpad lands two in one pump
2778
- tick often, so the second frame was drawn into the buffer the first
2779
- flip had taken off glass microseconds before — while the WindowServer
2780
- could still be compositing from it. A scroll's frame blits before it
2781
- repaints what the shift got wrong, and a composite caught between the
2782
- two shows the blit alone: a held `position: 'sticky'` header dragged up
2783
- a pixel for a frame, or the rows under it painted over it and then
2784
- corrected. Everything else in a scrolling pane moves anyway, which is
2785
- why this hid until something was meant to hold still. Now the first
2786
- event of a burst is answered on the spot and the rest fold into the
2787
- next paced frame, and a frame that answered the wheel restarts the
2788
- window's clock, so the pump does not flip again a few milliseconds
2789
- after it. Only the wheel: a resize paints into a pair it has just made,
2790
- and gating its ticks cost the live resize a frame after the drag
2791
- (`resize` in the presenter gate caught it). `test/cocoa-frames.test.js`.
2792
- - **A `<glarea>`'s frame and its overlay go out together**
2793
- (`CocoaWindow.surfaceFrameOwed`, `CocoaApp._tickFrames`). Two things
2794
- let the overlay a surface's children are painted on reach the screen
2795
- ahead of the GL frame it was painted for, while #641 had ordered the
2796
- two within a tick. An input answered at once, a press, a key or a wheel
2797
- notch, flushed the window inside its dispatch, overlay and all, and left
2798
- the GL frame the same input asked for on the clock, a refresh or more
2799
- later. And inside a tick the two were still two commits: the overlay is
2800
- flipped in a transaction of its own in the window's flush, the GL frame
2801
- after its draw, and a frame that rebuilt its scene put 10–16ms between
2802
- them. `<Flow>` over 400 widget bodies (react-x11-components'
2803
- `flow-stress` example) showed it: the first notch of a zoom took the
2804
- bodies and their cards off the overlay 20–25ms before the GL frame that
2805
- drew the cards in their place, and for that long the graph showed its
2806
- edges with nothing under them.
2807
- Now a window whose surface has a frame queued is left to the clock by the
2808
- early flush, and a tick runs such a window's frame and its surfaces'
2809
- inside one transaction, committed with the window's size, so every layer
2810
- the two changed lands in one commit. What an input gives up is the head
2811
- start of its 2D half on such a window; its GL half never had one.
2812
- `test/cocoa-glarea.test.js`.
2813
-
2814
- **What was left, in order of what it cost** — the three items of #445,
2815
- measured in the pass below: a full relayout of a large tree (44ms at 3,600
2816
- nodes, half of it the content floors measured from scratch), nothing drawn
2817
- at a level of detail, and the pump quantizing the frame period.
2818
-
2819
- ## Measured: incremental floors, the strip, the frame timer
2820
-
2821
- Written 2026-09-03 against the pass above (react-x11 2.5.0, `@windowkit/appkit`
2822
- 0.4.0), same machine, same bench, the surface presenter — the three items
2823
- #445 ranked, in its order.
2824
-
2825
- | what | before | after |
2826
- | -------------------------------------------------------------------- | --------------- | --------------- |
2827
- | `layout` — 3,662 nodes, the container's padding toggling: fps, flush | 18.4fps, 43.8ms | 34.4fps, 19.9ms |
2828
- | `tiny` — 14,452 nodes, 5px labels: the frame the grid reflows on | 320ms | 175ms |
2829
- | `anim` on a 75Hz monitor | 56.3fps | 70.4fps |
2830
- | `anim` on the 120Hz panel | 108–113fps | 108.6fps |
2831
-
2832
- - **The floors are measured incrementally** (`collectFloorStale`,
2833
- `probeHeightFloors`, `contentSpan`, src/nodes/window/floors.js). Every node keeps the
2834
- extent it was last measured at — what it needs from the box around it,
2835
- on each axis — and a measurement re-reads only the nodes whose subtree
2836
- changed, taking the rest at the number they carry. Which subtrees
2837
- changed is yoga's own record: a style setter, a child that came or went
2838
- and a text that asked to be re-measured all mark their node and every
2839
- node above it dirty, and the walk goes down that path and no other. So
2840
- a padding change on a container measures the container and nothing
2841
- below it, a row that mounts measures itself alone, and a colour change
2842
- measures nothing. The passes are paid for only where a floor is going
2843
- to be written from what they find: the width pass runs when content
2844
- changed under a node on a row's main axis, and the heights are settled
2845
- the other way round — the real layout runs first, a walk over the nodes
2846
- it moved asks each leaf, in JavaScript and out of its own layout cache,
2847
- whether its height at its new width is the height it had, and only if
2848
- one says otherwise (or content changed under a node a height floor is
2849
- written on) do the two height passes run and the layout again. On the
2850
- `layout` cell that is one pass over yoga where it was four, and a walk
2851
- over two nodes where it was four walks over 3,600: the floors went from
2852
- 22ms of the frame to 6, of which 4 is the one yoga pass. What is left
2853
- of that frame is paint — 1,200 rounded fills and 1,200 `CTLineDraw`s,
2854
- about 15ms — and React's own commit. `test/content-floors.test.js`
2855
- counts the nodes each kind of change measures and the passes it runs.
2856
- - **Text below a legible size is a strip** (`TextNode._paintsStrip`).
2857
- Under six logical pixels a label is a smudge of its ink, and a
2858
- rectangle per line over the band the letters sit in, in that ink at the
2859
- coverage small text has, is the same smudge for one fill instead of a
2860
- glyph run. Logical pixels, because what is legible is a physical
2861
- question: a 5px label is the same size on a 2x panel as on a 1x
2862
- monitor. `createRoot({ textStripBelow })` moves the line and `0` keeps
2863
- glyphs at every size (`REACT_X11_TEXT_STRIP_BELOW` for a process that
2864
- cannot reach `createRoot`); the layout is untouched either way — the
2865
- strip is as wide as the line the shaping settled. What it is worth is honest: on CoreGraphics a `CTLineDraw` of a three-glyph label costs about what a fill does, so the `tiny` cell on the surface presenter is unchanged by it (48–49fps either way) — its reflow frame is the 5,000 rounded cell fills, not the labels; on X11 the strip takes the cell's flush p95 from 86ms to 12ms, the glyph path's periodic expensive frames gone.
2866
- `test/text-strip.test.js`.
2867
- - **A frame due between two pump ticks gets a timer of its own**
2868
- (`CocoaApp._armFrameTimer`, `_frameDue`). The gate is now a millisecond
2869
- under the interval — a timer's drift, no more — and a frame the next
2870
- tick would be late for is run by a one-shot at the moment it is due,
2871
- which runs the frame queue and presents; a present commits its own
2872
- transaction, so nothing waits for the pump. The clock is kept on the
2873
- display's period rather than on the moment a frame happened to run, so
2874
- a tick that took a frame a little early or a timer that fired a little
2875
- late moves nothing. The 75Hz row is what it is worth; the 120Hz panel,
2876
- whose period the 8ms pump already fit, is unchanged. What the pump
2877
- alone still does is poll AppKit's events, which `pumpInterval` stays
2878
- the cadence of; the display link (§"Input and the run loop") would take
2879
- input latency under it. `test/cocoa-frames.test.js`.
2880
-
2881
- **What is left, in order of what it costs:** the paint of a large tree —
2882
- the fills and the line draws, which only not repainting can take away: a
2883
- paint cache over the cells that did not change, or the layers presenter —
2884
- then yoga's own pass at about a millisecond per thousand nodes, and React's
2885
- commit of a tree that re-renders unmemoized.
2886
-
2887
- ## Measured: the flood, under the frame pacer
2888
-
2889
- Written 2026-09-07 against react-x11 2.8.3 and `@windowkit/appkit` 0.6.0,
2890
- same machine, the surface presenter, `npm run bench:presenters --
2891
- --scenario=stream --columns=surface`: a window-sized element repainting its
2892
- whole box on every claim, claimed every 2ms by a timer standing in for a
2893
- producer — the shape of a terminal under `cat`. The rows are `--frame-rate`
2894
- ([elements.md](elements.md#framerate--pacing-the-frames-under-a-flood)).
2895
-
2896
- | `--frame-rate` | fps | paint share of wall | cpu | producer: claims/s |
2897
- | -------------- | ----: | ------------------: | --: | -----------------: |
2898
- | `display` | 120.0 | 58% | 72% | 244 |
2899
- | `adaptive` | 36.1 | 21% | 27% | 529 |
2900
- | `30` | 29.7 | 19% | 24% | 539 |
2901
- | `throughput` | 8.8 | 9% | 13% | 592 |
2902
-
2903
- The default paints every refresh and starves its producer of half its
2904
- ticks; `'adaptive'` holds paint at the budget and gives the producer all of
2905
- them. The design and the rest of the numbers are in
2906
- [architecture/frame-pacing.md](architecture/frame-pacing.md). Two things
2907
- here are the backend's own: `CocoaWindow.present` reports the flip and its
2908
- catch-up copy to the node, so the frame is priced by what the thread
2909
- spent and not just by the flush; and `CocoaApp._requestFrame` arms the
2910
- frame timer for a request that arrives between two pump ticks, so a held
2911
- claim lands at its wait rather than at its wait rounded up to the pump.
2912
- The structural gate is unchanged: every rule holds at the default, which
2913
- is what every scenario but `stream` runs at.
2914
-
2915
- ## Measured: the swapchain and the WindowServer's hold
2916
-
2917
- Written 2026-09-17 against react-x11 2.15.3 and `@windowkit/appkit` 0.11.0,
2918
- the M1 Pro's 120Hz panel, for #602: a menu-bar popover flickered while its
2919
- spectrum animated. The scene is that popover's shape — a borderless,
2920
- transparent 320×440pt window with eight bars on `height` loops, which
2921
- repaint the whole window every frame (#603) — and every write into an
2922
- IOSurface (a copy, a clear, a fill, a glyph run) is checked with
2923
- `surfaceIsInUse` at the moment it lands. Three runs of 4s per row,
2924
- alternating the old swapchain and this one on a loaded machine; clicking is
2925
- a press and a release every 100ms through `postMouseEvent`, each answered on
2926
- the event.
2927
-
2928
- | mode | catch-up copies into a held buffer | first draws of a frame into a held buffer |
2929
- | ------------------ | ---------------------------------- | ----------------------------------------- |
2930
- | pump | 1,439 of 1,440 → 0 of 1,426 | 569 of 1,440 → 0 of 1,426 |
2931
- | pump, clicking | 1,671 of 1,680 → 0 of 1,680 | 543 of 1,680 → 0 of 1,680 |
2932
- | threaded | 1,236 of 1,387 → 0 of 1,381 | 833 of 1,387 → 0 of 1,381 |
2933
- | threaded, clicking | 1,314 of 1,590 → 0 of 1,571 | 660 of 1,591 → 0 of 1,571 |
2934
-
2935
- Counting every write, not only the first of a frame: 148,839 of 386,377
2936
- landed in a held buffer before, and 0 of 383,938 after.
2937
-
2938
- - **A frame takes its buffer at its first draw** (`CocoaWindow._takeBack`).
2939
- The catch-up copy used to follow the flip: in pump mode microseconds
2940
- after it, into the buffer the flip had just taken off glass, and on a
2941
- worker after `surface-released`, which says the flip was applied and not
2942
- that the WindowServer let go. Now nothing is chosen at the flip. The next
2943
- frame's first draw takes, of the buffers not on glass, one that
2944
- `surfaceIsInUse` answers free — on a worker, one whose release has come
2945
- as well — and of those the one on glass most recently, which missed the
2946
- fewest frames.
2947
- - **Each buffer keeps what it missed.** A present adds its damage to every
2948
- buffer it did not show, so a buffer that comes back several frames
2949
- behind is caught up with all of them; past 64 rects, whole.
2950
- - **The chain grows to four buffers, on demand.** The WindowServer let go
2951
- of a buffer a median of about 9ms after a flip replaced it, and under
2952
- 40ms at the 99th percentile. So at 120Hz the buffer a flip replaced is
2953
- often still held when the next frame draws, and the one before it
2954
- rarely: an animation mostly needs three. An input answered on the spot
2955
- between two of its frames puts two flips inside one refresh, and a
2956
- loaded machine holds buffers longer; four covered every run above. With
2957
- every buffer held and the chain full, the frame takes the one off glass
2958
- longest — the one write left that can land in a held buffer.
2959
- - **…and gives them back.** A second after a window's last frame, a chain
2960
- that grew keeps two buffers: the one on glass and the one on glass
2961
- before it. A still window holds the backing store it always did, and its
2962
- next frame takes the kept buffer without making one.
2963
- - **A new chain starts with one buffer.** A live-resize tick paints its size
2964
- once and the next tick makes a chain of its own, so a tick now allocates
2965
- one window-sized IOSurface instead of two, and no full copy follows its
2966
- flip: the bench's `resize` cell made 41 surfaces for its 40 ticks where it
2967
- made 80, and uploaded 61kpx a frame where it copied 2,611.
2968
- - **Not done: pacing the input.** #602 also suggested gating every input's
2969
- early present the way the wheel's is gated (`_flippedRecently`), which
2970
- keeps a burst to one flip per refresh and the chain to three. It would
2971
- also move the answer to a press on an animating window to the next paced
2972
- frame, up to a refresh later. The fourth buffer spends memory instead of
2973
- latency, and only while a window animates and takes input at once.
2974
-
2975
- `test/cocoa-frames.test.js`, `test/cocoa-threaded.test.js` and
2976
- `test/cocoa-scroll-blit.test.js` hold the rules, the last in pixels. What
2977
- is not measured is a torn frame on glass, before or after: `snapshotWindow`
2978
- renders the window from the buffer just flipped in, not the one being
2979
- written, and cannot see this race either way.
2980
-
2981
- ## Measured: a scroll's band, copied once
2982
-
2983
- Written 2026-09-25 against react-x11 2.22.6 and `@windowkit/appkit` 0.13.0,
2984
- the M1 Pro at scale 2. A scroll blit's frame used to write its band twice.
2985
- The frame's first draw took a buffer and caught it up to the frame on glass,
2986
- and the catch-up owed the band, because the frame before had blitted it too.
2987
- Then `scrollSurface` moved the band inside that buffer. Now a scroll that is
2988
- its frame's first draw takes its buffer with the band already moved:
2989
- `blitSurface` copies it across from the frame on glass at the shift, and
2990
- the catch-up copies everything else (`CocoaWindow._takeBack`). Every pixel
2991
- ends where the two passes left it, the strips the shift exposes included, so
2992
- the frame repaints what it did. A scroll later in a frame, a chain of one, a
2993
- buffer that owes nothing or a bridge without `blitSurface` moves the band in
2994
- place, as before.
2995
-
2996
- The time spent in the bridge's copies, in each probe of the components
2997
- sweep (`scripts/bench/sweep/` in react-x11-components), the median of three
2998
- runs alternating the two:
2999
-
3000
- | scroll | copies per frame | the window's flush, p50 |
3001
- | ----------------------------------- | ---------------- | ----------------------- |
3002
- | `<CodeEditor>`, 50,000 lines, wheel | 2.09 → 1.61ms | 4.00 → 3.72ms |
3003
- | `<Markdown>`, 600 KB, wheel | 1.44 → 1.01ms | 6.02 → 5.55ms |
3004
- | `<Table>`, 100,000 rows, wheel | 1.15 → 0.99ms | 2.66 → 2.68ms |
3005
- | `<Flow>`, a pan of the cards scene | 1.36 → 0.80ms | — |
3006
-
3007
- `scripts/cocoa-blit-probe.mjs` counts the frames that take the band across
3008
- (`across`): 499 of its 500 on the real bridge, with the two windows'
3009
- pictures identical on every frame.
3010
-
3011
- **A copy measured in a loop is not the copy a frame makes.** A 15 MB copy
3012
- between two IOSurfaces takes 0.34 ms back to back on this machine, 45 GB/s.
3013
- One copy every 16 ms, with the thread asleep in between, takes 1.8 ms at
3014
- the median and 4 ms at the 90th percentile. The band copies above run at
3015
- that rate: about 8.5 GB/s. It is not the surface: plain `malloc`ed memory
3016
- slows down the same way, and a buffer the WindowServer is showing copies at
3017
- full speed in a loop. It is not the thread's QoS class either:
3018
- user-interactive, default and utility all measure alike. Splitting the rows over two to eight GCD threads makes the
3019
- loop twice as fast and the paced copy no faster. So the time is in the
3020
- memory system waking up, not in the core doing the copy. What a frame can
3021
- do about it is copy fewer bytes, and a benchmark of a copy has to leave the
3022
- thread idle between copies, the way a frame clock does.
3023
-
3024
- ## Measured: a paragraph laid out again
3025
-
3026
- Written 2026-09-25 against react-x11 2.22.6 and `@windowkit/appkit` 0.13.0
3027
- with windowkit/appkit#75, which 0.14.0 released, on the M1 Pro at scale 2. A
3028
- window resize lays every paragraph of a document out at the new width, and
3029
- `createLayout` was most of that frame. Timed inside the verb over the reflow
3030
- of a 600 KB document, it went to four places:
3031
-
3032
- | part of `createLayout` | `<Markdown>` | `<Html>` |
3033
- | ---------------------------------------- | ------------ | -------- |
3034
- | the attributed string from the spans | 30% | 27% |
3035
- | `CTTypesetterCreateWithAttributedString` | 37% | 40% |
3036
- | breaking lines, the `CTLine`s and runs | 16% | 16% |
3037
- | the result's objects, one a line and run | 17% | 17% |
3038
-
3039
- The first two are the text becoming glyphs, and they are the same at every
3040
- width. So the engine keeps a paragraph's typesetter
3041
- (`CocoaFontManager._keptTypesetter`), found by everything shaping reads — the
3042
- spans' text, faces, colours, features and spacing, and the direction — and a
3043
- layout of the same paragraph at another width, alignment, line height or
3044
- line cap is broken from it (appkit's `typesetter`). A min-content
3045
- measurement lays out other text, the paragraph broken at every opportunity,
3046
- and keeps its own. The geometry comes back packed, two `Float64Array`s
3047
- rather than an object per line and run (`unpackLines`). The kept
3048
- typesetters hold at most a megabyte of text, about 25 MB of CoreText's, and
3049
- the paragraph laid out least recently is let go past it
3050
- (`releaseTypesetter`); a font the app loads lets them all go. A bridge from
3051
- before the verbs, appkit 0.13 and earlier, is asked the old way.
3052
-
3053
- Separately, a layout's code point to code unit table
3054
- (`codeUnitOffsets`) is null for text with no surrogate pair, which is
3055
- nearly all text: it was a walk of every character of a document at every
3056
- width.
3057
-
3058
- | 600 KB document, `docsweep.tsx` | reflow step, p50 | first paint |
3059
- | ------------------------------- | ---------------- | -------------- |
3060
- | `<Markdown>` | 335 → 228 ms | 1255 → 1174 ms |
3061
- | `<Html>` | 574 → 273 ms | 549 → 526 ms |
3062
-
3063
- First paint moves too, because a paragraph's max-content measurement and
3064
- its wrapped layout are now one shaping. `test/cocoa-kept-typesetters.test.js`
3065
- holds the keys, the eviction and the unpacking over a fake bridge, and,
3066
- where the bridge keeps typesetters, that every layout made from one is the
3067
- layout made from the spans.
3068
-
3069
- ## Measured: a pane's presents, answered
3070
-
3071
- Written 2026-10-04 against react-x11 2.40.0 with #870 and #875 and
3072
- `@windowkit/appkit` 0.25.0, on the M1 Pro's 120Hz panel at scale 2, with the
3073
- browser example in `@react-x11/components` on a Wikipedia article. A preload
3074
- logged, in the pane, the first write into each ring buffer after a present —
3075
- the seq the buffer was last presented under, the newest `pane-shown` heard,
3076
- and `surfaceIsInUse` — and in the host every lookup, so each write could be
3077
- held against the moment the host looked its buffer up. The scenarios: the
3078
- window dragged 4 px every 16 ms; and the pane scrolling itself a notch every
3079
- 8 ms, or every 4 for a burst — input the host's thread has no part in, as
3080
- momentum or a page's own animation is — with the host idle, or busy 60 ms in
3081
- every 150. Two runs of each, master and branch interleaved.
3082
-
3083
- | per 3 s of scrolling | before | after |
3084
- | ---------------------------------------------------------- | ------------ | ----------- |
3085
- | busy host: frames drawn into a buffer it looked up after | 93–98 of 376 | 0 |
3086
- | busy host: frames drawn into the buffer on the layer | 19 | 0 |
3087
- | any host: frames drawn into a buffer the WindowServer read | 155–177 | 0–2 |
3088
- | busy host: input to the host's lookup, p50 / p99 | 7.9 / 64 ms | 8.5 / 64 ms |
3089
- | idle host: presents a second, input to present p50 | 119, 5.9 ms | 119, 6.6 ms |
3090
- | burst: presents a second, input to present p50 | 118, 6.0 ms | 118, 6.6 ms |
3091
-
3092
- Three findings decided the design (`CocoaPaneWindow.frameHeld`, `_takeBack`,
3093
- `frameInFlight`):
3094
-
3095
- - **The host two presents behind is ordinary.** A host busy with its own
3096
- frame stops answering, and triple buffering assumed it never would: the
3097
- pane drew into the buffer on the layer, or into one a present still in
3098
- the channel named, which the host then showed half drawn. A frame now
3099
- takes a buffer only once the host has answered a present after the last
3100
- one that named it, and waits on the clock for the answer when there is
3101
- none, 250 ms at most.
3102
- - **An answer is a lookup, not a frame on glass.** The buffer the layer let
3103
- go of stays in use for 8 ms after the pane hears the answer at the median,
3104
- 13–22 at the 90th percentile; and a host back from a stall answers a run
3105
- of presents at once while its UI thread commits later. Taken on the answer
3106
- alone, the frame after each stall drew into the buffer the screen still
3107
- showed, 18–23 frames of 285. So a frame takes a buffer the WindowServer
3108
- has let go of as well, asked every millisecond for a frame interval at
3109
- most. The catch-up copy moved from the present to the frame's first draw
3110
- for the same reason — at the present the buffer two behind was still read
3111
- in nearly half the frames of a scroll — and that is the 0.6 ms the table
3112
- shows: the copy is on the input's path now. Doing it at the present when a
3113
- free buffer is already there is the obvious follow-up.
3114
- - **The input gate keeps its frame interval.** Gating on the answer alone
3115
- answered an input 4 ms sooner at the median, and drew a frame per input
3116
- past the display's rate: 245 a second at 250 inputs a second, 355 at 1000,
3117
- the pane busy 58% of the time against 33%, and the frames that outran the
3118
- WindowServer drew into buffers it still read. Nothing the pane can ask
3119
- says when a frame is on glass — a buffer reads in use from the host's
3120
- lookup, not from the refresh — and a gate on the clock's own grid in
3121
- place of the interval held every input to the paced frame just the same.
3122
-
3123
- A drag is faster for the second: master copied the whole frame into the
3124
- next buffer at every present, and the next frame of a drag is a new size on
3125
- a new ring, so the copy was thrown away every time — 126 copies in 126
3126
- presents, against 55 in 174 now. Frames during the drag, the mean of six
3127
- interleaved pairs: 52 a second before, 59 after.
3128
- `test/cocoa-frames.test.js` holds each rule over the fake bridge, and each
3129
- fails its own test when taken out.
539
+ `make-app.sh` builds a developer's bundle, which runs where it was built;
540
+ [packaging.md](packaging.md#tier-5--a-macos-app) has the recipes. What
541
+ differs on macOS:
542
+
543
+ - **Signing.** A downloaded app needs a Developer ID signature with the
544
+ hardened runtime, then notarization. Sign inside-out, not with `--deep`:
545
+ `calayers.node` first, without entitlements, then the app with Bun's five
546
+ entitlements ([packaging.md](packaging.md#developer-id-or-the-app-store)).
547
+ For a colleague, ad-hoc signing (`--sign -`) and
548
+ `xattr -dr com.apple.quarantine` on their end are the short path.
549
+ - **The Mac App Store** requires App Sandbox, under which a
550
+ `bun build --compile` binary does not start. A Node single executable in
551
+ the same bundle shape does, so that is the store build; Bun is fine for
552
+ Developer ID.
553
+ - **Architecture.** A compiled binary and the bridge's prebuild are each for
554
+ one CPU; a universal bundle is two builds and `lipo`, for the executable
555
+ and `calayers.node` alike.
556
+ - **Size.** About 70 MB with bun, nearly all of it the runtime; 142 MB as a
557
+ Node single executable.
3130
558
 
3131
559
  ## Testing
3132
560
 
3133
- The strategy mirrors the X11 suite's shape rather than its mechanism:
3134
-
3135
- - **Unit tier, headless, any OS**: `backend: 'cocoa-mock'` — the Cocoa
3136
- presenter running against a recorded layer-op sink and a metrics-only
3137
- TextEngine stub (the `mock-app.js` precedent). Asserts _which layer
3138
- mutations a commit produces_ — the retained twin of the dirty-rect
3139
- tests — plus all the shared behavioral tests, which run on the node
3140
- model and don't care about the presenter.
3141
- - **Integration tier, macOS**: real windows on a runner's Aqua session;
3142
- pixels via window snapshot; input via the posted-event path (the
3143
- harness's `fireEvent` transport). Per-backend pixel baselines; the
3144
- scenario-level structural assertions (find-by-color, behavior) shared.
3145
- - **The bench twin** (§Measure first) is part of the definition of done
3146
- for the presenter, not an afterthought — the scroll and transition
3147
- scenarios are the reason Tier L exists, so they are what fences it.
3148
-
3149
- ### The swapchain, in pixels
3150
-
3151
- Counting bridge calls is the right shape for a frame clock and the wrong
3152
- one for a fast path whose only observable is the picture. The scroll blit
3153
- is the case: `scrollRegion` shifts a band inside the **back** buffer, the
3154
- frame repaints only the strips the shift exposed, and what carries the
3155
- band into the other buffers is the catch-up copy over the rects
3156
- `noteFrameDamage` collected, when a frame takes one of them. A catch-up
3157
- covering the wrong rects leaves a buffer one shift stale, and a pan then
3158
- smears — a staircase of duplicated content at increasing offsets, which is
3159
- what issue #458 reports.
3160
-
3161
- Two harnesses answer it, and they answer the same question:
3162
-
3163
- - `test/cocoa-scroll-blit.test.js` runs in CI on any OS. Its bridge holds
3164
- real rasters — a byte per pixel, `ScrollSurface`, `CopySurfaceRegion` and
3165
- `BlitSurface` written out with the natives' own clamping — and the proof
3166
- obligation is
3167
- one line: render the same pane into two windows, delete `scrollRegion`
3168
- on the second (which is how the scroll blit feature-detects a backend without
3169
- the fast path), and the buffer handed to the layer must hold the picture
3170
- the repaint painted. Then the frames a pan meets: bursts coalesced into
3171
- one frame, a frame painted but not presented, a present held or
3172
- occluded, a resize mid-gesture, fuzzers over all of it — and a third
3173
- window whose buffers the WindowServer holds for up to two frames after
3174
- each flip (#602), so that a buffer comes back several frames behind,
3175
- where no write may land in a held buffer or in the one on glass. The
3176
- heaviest of those run twice: the band taken across with the buffer, and
3177
- a bridge without `blitSurface`, moving it in place.
3178
- - `scripts/cocoa-blit-probe.mjs` is the same comparison over the real
3179
- bridge — CoreGraphics contexts, IOSurfaces, an actual swapchain — for
3180
- when the question is whether the bridge does what the model says. Not
3181
- part of the suite (it needs macOS); run it when the swapchain or the
3182
- bridge's surface verbs change. It runs in pump mode: its frames are
3183
- driven synchronously, and a worker's would wait on releases the loop
3184
- never yields for.
3185
-
3186
- Both also count the blits, because two windows that both repainted agree
3187
- about everything and prove nothing.
3188
-
3189
- ## The plan
3190
-
3191
- Each phase has an exit that makes the next safe to start; the first two
3192
- run against today's renderer with no core changes.
3193
-
3194
- - **Phase 0 — the bridge contract.** Close the POC gaps
3195
- that block everything else: event modifiers + per-window routing +
3196
- wheel/key posting, window delegates, `insertSublayer`, raw-buffer/
3197
- IOSurface contents, batched ops, display link, run-loop drain
3198
- (option 2), and the menu API skeleton. Publish 0.2.0 with prebuilds.
3199
- _Exit: a demo app live-resizes with JS-driven relayout tracking the
3200
- drag, and a native menu's items enable/disable from JS while open._
3201
- - **Phase 1 — the surface backend (Tier S).** `src/cocoa/app.js`
3202
- implementing the ntk app/window/ctx/fonts contract; CoreText
3203
- TextEngine v1; event mapping; `createRoot({ backend })` selection;
3204
- `cocoa-mock` harness backend. Run the examples; port the test suite
3205
- where the mock reaches. Build the bench twin and record Tier-S
3206
- baselines, including input-to-photon. _Exit: `examples:widgets`,
3207
- `tasks`, `form` fully interactive natively; suite green on
3208
- cocoa-mock; baselines recorded._
3209
- - **Phase 2 — the presenter seam (X11-neutral refactor).** Extract
3210
- paint/damage/flush into the X11 presenter behind the
3211
- invalidate/flush contract; nodes keep model+events+layout. **Zero
3212
- behavior change**, pinned by pixel gates, dirty-rect tests and
3213
- `bench --check` on X11. _Exit: X11 suite and bench byte- and
3214
- number-identical; the presenter interface documented in
3215
- docs/extending.md terms._
3216
- - **Phase 3 — the layer presenter (Tier L core).** Visuals for
3217
- box/text/image/canvas + raster fallback; clipping and scrolling via
3218
- layer properties; popups as panels; `CATransaction` per commit.
3219
- Measure against Tier-S baselines; apply the §Measure-first gates
3220
- (layer-collapse and text-atlas work only where numbers demand).
3221
- _Exit: the bench twin shows Tier L beating Tier S on scroll and
3222
- transition scenarios and regressing none; examples run on layers;
3223
- Tier S demoted to fallback/A-B switch._
3224
- - **Phase 4 — the platform (macOS services).** NSMenu global menu +
3225
- `Primary` chords; native appearance/screens/scale/window-state;
3226
- pasteboard clipboard; native control bezels behind
3227
- `controls: 'native'`; native file dialogs; `useSupports` additions.
3228
- _Exit: `examples:menu` is the system menu bar; `widgets` renders
3229
- native bezels; appearance flips live without osascript._
3230
- - **Phase 5 — the deep integrations.** DnD; NSAccessibility bridge over
3231
- the a11y model; IME via NSTextInputClient; transitions offloaded to
3232
- CA (+ `opacity`, then `transform`, capability-gated); `<glarea>` over
3233
- CAOpenGLLayer/ntk-cgl; tray via NSStatusItem; app lifecycle
3234
- (open-URL → `onAppOpen`). Each is its own bounded design against a
3235
- by-then-stable presenter. _Exit: per-feature; the a11y bridge's is
3236
- VoiceOver reading the widgets example the way Orca reads it today._
3237
-
3238
- ## Open questions
3239
-
3240
- 1. **The name** (§Public API #8) — decide before Phase 4 ships anything
3241
- user-visible.
3242
- 2. **Layer granularity numbers** — layer-per-drawn-node is the bet;
3243
- collapse thresholds and the text-atlas question are Phase-3
3244
- measurements, and the §Measure-first gates decide them.
3245
- 3. **Run-loop option 3** — is the drain (option 2) enough for menu
3246
- tracking + live resize + drag sessions in practice, or does a real
3247
- inversion become necessary? Phase 0/1 experience answers it.
3248
- Answered 2026-09-11, and neither: a nested drain never drains
3249
- microtasks, and an inversion needs whoever owns `main()`. The answer is
3250
- JS on a worker with AppKit parked on the main thread — §"JS on a worker:
3251
- a UI thread of the bridge's own", with the bridge's half filed as
3252
- [windowkit/appkit#49](https://github.com/windowkit/appkit/issues/49).
3253
- 4. **Transition semantics under CA** — retarget/interrupt parity with
3254
- the JS loop needs a written spec before Phase 5 flips the default.
3255
- 5. **Cross-backend text metrics** — accepted divergence (per-backend
3256
- pixel baselines) vs. the shared-shaping option if component layout
3257
- portability bites in practice.
3258
- 6. **Bun on the Cocoa backend** — N-API under Bun is expected to work;
3259
- verify early (Phase 1) since single-file packaging is a stated goal.
3260
- Answered in two halves: `make-app.sh` is that single file and it runs,
3261
- and it cannot be the App Store one — a bun binary does not start under
3262
- App Sandbox (§Distributing the bundle), so that build is node's SEA.
3263
- 7. **`@react-x11/components` on Cocoa** — the seven registered elements
3264
- should light up via the raster path untouched; the X-only corners
3265
- (tray-host, embed, media-player embedding, `serverTime`) need
3266
- per-module capability statements rather than silent absence.
3267
-
3268
- ## References
3269
-
3270
- - [wayland.md](wayland.md) — the sibling RFC; shared inventory and the
3271
- opposite rendering inversion.
3272
- - [remote.md](remote.md), [embedding.md](embedding.md),
3273
- [globalmenu.md](globalmenu.md), [appearance.md](appearance.md),
3274
- [filedialog.md](filedialog.md), [accessibility.md](accessibility.md),
3275
- [events.md](events.md), [styling.md](styling.md),
3276
- [extending.md](extending.md), [testing.md](testing.md) — the
3277
- per-feature contracts this plan maps onto Cocoa.
3278
- - Apple: Core Animation Programming Guide; `CATransaction`,
3279
- `CALayer.shadowPath`, `contentsCenter`, `NSTextInputClient`,
3280
- `NSAccessibilityElement`, `NSDraggingSession`, `NSPasteboard`,
3281
- `CADisplayLink` (macOS 14+).
3282
- - libuv — "Embedding libuv in other event loops" (the run-loop drain's
3283
- recipe, pointed at AppKit).
3284
- - Distribution: Apple, "App Sandbox" (the store requirement) and "Porting
3285
- just-in-time compilers to Apple silicon" (`allow-jit` matters only under
3286
- the hardened runtime); Apple DTS on the forums, "Signing a Mac Product
3287
- For Distribution" (thread 128166) and "Creating distribution-signed code
3288
- for Mac" (701514) — inside-out signing, no `--deep`; Bun, "Code signing
3289
- on macOS" in the executables guide; Node, `tools/osx-entitlements.plist`;
3290
- oven-sh/bun#15661 for bun under App Sandbox.
3291
- - WebKit/Gecko form-control rendering via offscreen `NSCell` drawing —
3292
- the native-bezel technique the POC reproduces.
3293
- - [`@windowkit/appkit`](https://www.npmjs.com/package/@windowkit/appkit) —
3294
- the bridge this document specified, as published. §"The bridge: the
3295
- required API" was its 0.2.0 contract.
561
+ `react-x11/test` drives the **X11 backend on every platform, macOS
562
+ included**; there is no `'cocoa'` backend for `renderX11()`. Test the tree,
563
+ queries and behaviour there, which are shared. Pixels are per-backend
564
+ (CoreText is not ntk's rasterizer), so native bezels, the `NSMenu` and
565
+ pasteboard flavours are checked by running on a Mac
566
+ ([testing.md](testing.md)). A window's `snapshot()` resolves to its
567
+ composited pixels, in the display's colour profile.
568
+
569
+ ## Not supported yet
570
+
571
+ - **VoiceOver**: no `NSAccessibility` bridge, and AT-SPI is off here, so a
572
+ Cocoa app is not reachable by assistive technology yet. Write roles and
573
+ labels anyway; the bridge will read them ([accessibility.md](accessibility.md)).
574
+ - **Input methods**: no `NSTextInputClient` — no marked text, Press-and-Hold
575
+ accents or CJK input ([events.md](events.md#input-methods)).
576
+ - **Nested `<window>` elements** throw; a `<glarea>` is the only child
577
+ surface.
578
+ - **`Surface` `format: 'a8'`** throws — allocate `argb32` and tint through
579
+ `fillStyle`/`globalAlpha`.
580
+ - **`createPattern`**, and the X-only `Picture`, `<image picture>` and
581
+ `<image drawable>`.
582
+ - **The eyedropper over a bridge older than 0.9**: `useEyedropper().supported`
583
+ is false — leave the eyedropper button undrawn.
584
+ - **`useKeepAwake()`**: no macOS rung.
585
+ - **`transform`** as a style property (on any backend).
586
+ - **`transientFor`** and the `sticky`, `below`, `skip_taskbar` and `shaded`
587
+ states: inert.
588
+ - **A `'cocoa'` backend for `react-x11/test`.**
589
+ - **Dropped images** while the app runs on a worker.
590
+ - **Never, by design**: `<foreign>` and XEmbed, a window manager
591
+ (`examples/wm.jsx`), remoting over `ssh -X` — run the X11 backend through
592
+ XQuartz for those.