react-x11 2.51.0 → 2.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +87 -54
  2. package/docs/README.md +410 -0
  3. package/docs/accessibility.md +628 -0
  4. package/docs/appearance.md +416 -0
  5. package/docs/architecture/accessibility.md +470 -0
  6. package/docs/architecture/animation.md +707 -0
  7. package/docs/architecture/container-queries.md +525 -0
  8. package/docs/architecture/custom-layout.md +334 -0
  9. package/docs/architecture/drag-and-drop.md +941 -0
  10. package/docs/architecture/element-layer-contents.md +622 -0
  11. package/docs/architecture/frame-pacing.md +255 -0
  12. package/docs/architecture/grid-layout.md +358 -0
  13. package/docs/architecture/protocol-efficiency.md +357 -0
  14. package/docs/architecture/video.md +1149 -0
  15. package/docs/architecture/windowed-regions.md +854 -0
  16. package/docs/click-to-component.md +125 -0
  17. package/docs/clipboard.md +281 -0
  18. package/docs/components.md +1749 -0
  19. package/docs/dbus.md +277 -0
  20. package/docs/debugging.md +464 -0
  21. package/docs/desktop-calendar.md +284 -0
  22. package/docs/desktop.md +648 -0
  23. package/docs/devtools.md +250 -0
  24. package/docs/drag-and-drop.md +663 -0
  25. package/docs/ecosystem/animation.md +394 -0
  26. package/docs/ecosystem/data-fetching.md +201 -0
  27. package/docs/ecosystem/dev-tooling.md +436 -0
  28. package/docs/ecosystem/forms.md +313 -0
  29. package/docs/ecosystem/headless.md +219 -0
  30. package/docs/ecosystem/i18n.md +89 -0
  31. package/docs/ecosystem/icons.md +410 -0
  32. package/docs/ecosystem/layout.md +216 -0
  33. package/docs/ecosystem/routing.md +146 -0
  34. package/docs/ecosystem/state.md +361 -0
  35. package/docs/ecosystem/testing.md +500 -0
  36. package/docs/ecosystem/theming.md +540 -0
  37. package/docs/ecosystem.md +473 -0
  38. package/docs/elements.md +2762 -0
  39. package/docs/embedding.md +288 -0
  40. package/docs/events.md +1025 -0
  41. package/docs/extending.md +2242 -0
  42. package/docs/eyedropper.md +223 -0
  43. package/docs/filedialog.md +258 -0
  44. package/docs/frame.md +580 -0
  45. package/docs/gl.md +170 -0
  46. package/docs/globalmenu.md +278 -0
  47. package/docs/glx.md +137 -0
  48. package/docs/macos.md +3295 -0
  49. package/docs/notifications.md +164 -0
  50. package/docs/packaging.md +611 -0
  51. package/docs/permissions.md +148 -0
  52. package/docs/react-features.md +430 -0
  53. package/docs/remote.md +223 -0
  54. package/docs/scale.md +339 -0
  55. package/docs/security.md +132 -0
  56. package/docs/styling.md +1928 -0
  57. package/docs/system.md +599 -0
  58. package/docs/testing.md +514 -0
  59. package/docs/typescript.md +117 -0
  60. package/docs/uri-schemes.md +380 -0
  61. package/docs/wayland-backend.md +591 -0
  62. package/docs/wayland.md +545 -0
  63. package/docs/windows-embedding.md +271 -0
  64. package/docs/windows-gl.md +221 -0
  65. package/docs/windows-integrations.md +233 -0
  66. package/docs/windows.md +1404 -0
  67. package/package.json +10 -3
  68. package/src/bus.js +39 -26
  69. package/src/cli/create.d.ts +43 -0
  70. package/src/cli/create.js +305 -0
  71. package/src/cli/env.js +484 -0
  72. package/src/cli/index.js +29 -2
  73. package/src/cli/templates/AGENTS.template.md +195 -0
  74. package/src/cli/templates/DESIGN.template.md +269 -0
  75. package/src/desktopintegration.js +2 -1
  76. package/src/desktopsettings.js +193 -24
  77. package/src/filedialog.js +3 -3
  78. package/src/keyboardstate.js +28 -0
  79. package/src/node.d.ts +10 -10
  80. package/src/portal.js +31 -0
  81. package/src/screencolor.js +5 -10
  82. package/src/types/system.d.ts +8 -6
  83. package/src/wayland/app.js +59 -0
  84. package/src/wayland/clipboard.js +14 -7
  85. package/src/wayland/connection.js +3 -0
  86. package/src/wayland/context2d.js +129 -10
  87. package/src/wayland/protocols/index.json +6 -0
  88. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  89. package/src/wayland/seat.js +28 -1
  90. package/src/wayland/swapchain.js +23 -0
  91. package/src/wayland/xkb.js +137 -0
package/docs/remote.md ADDED
@@ -0,0 +1,223 @@
1
+ # Remote display
2
+
3
+ This is the case the architecture is categorically better at, and the one
4
+ nothing else on the page has told you about.
5
+
6
+ A react-x11 app **draws over the wire**. It does not render pixels and ship
7
+ them; it sends X11 drawing requests — rectangles, glyph runs, composites —
8
+ and the X server on the far end turns them into pixels. So "running the app
9
+ somewhere else" is not a feature bolted on top. It is what the protocol is,
10
+ and the program does not know the difference.
11
+
12
+ ```sh
13
+ ssh -X buildbox
14
+ npm start # the window opens on your desktop
15
+ ```
16
+
17
+ Nothing in the app changes. No VNC, no browser, no streaming daemon, no port
18
+ to forward, no second copy of the UI.
19
+
20
+ **This is the X11 backend**, and it is the reason X11 stays the flagship
21
+ target rather than becoming the legacy one: the Cocoa, win32 and Wayland
22
+ backends have no wire at all, so nothing on this page applies to them. That
23
+ matters in one specific case — **an app running on a Mac or on Windows and
24
+ drawing somewhere else**. `$DISPLAY` in the environment does not choose the
25
+ backend there; `backend: 'auto'` sees darwin and picks Cocoa, or win32 and
26
+ picks the win32 backend, and the window opens on the local machine. Say
27
+ which you want:
28
+
29
+ ```sh
30
+ REACT_X11_BACKEND=x11 DISPLAY=thin-client:0 npm start
31
+ ```
32
+
33
+ `createRoot({ display: … })` or `createRoot({ stream: … })` also settle it —
34
+ naming an X endpoint in code _is_ choosing X11, because an ignored
35
+ connection option would be worse than either answer. An app running on Linux
36
+ and drawing to a Mac's XQuartz is not affected: both ends are X11 already.
37
+
38
+ ## What it costs, measured
39
+
40
+ The honest comparison is with pixel streaming (VNC, RDP, a
41
+ `--remote-debugging` browser), because that is what people reach for
42
+ instead. The trade is bytes-per-frame against round-trips-per-interaction.
43
+
44
+ From `scripts/bench/baseline.json`, which measures against node-x11's
45
+ in-process server — so these are exact protocol costs, not estimates:
46
+
47
+ | scenario | requests | bytes out | replies | stalls |
48
+ | ---------------------------------- | -------- | --------- | ------- | ------ |
49
+ | mount: a window, 40 boxes + labels | 110 | 4.2 kB | 32 | 0 |
50
+ | 50 filled rounded boxes | 122 | 6.6 kB | 7 | 0 |
51
+ | a paragraph of text | 39 | 6.0 kB | 7 | 0 |
52
+ | 10 wheel notches over 500 rows | 224 | 11.2 kB | 22 | 4 |
53
+ | 5 drag steps of a card of widgets | 72 | 4.3 kB | 11 | 0 |
54
+
55
+ **A whole window appears for about four kilobytes.** A single 1280×800 frame
56
+ of VNC, even well compressed, is tens to hundreds of kilobytes, and it is
57
+ sent again every time anything moves. Scrolling a 500-row table ten notches
58
+ costs 11 kB here because only the damaged strip is redrawn (the
59
+ [scroll-blit path](../AGENTS.md), issue #138); the same scroll under pixel
60
+ streaming is ten full frames. Dragging a card with a background of its own
61
+ is the same trade: each step copies the card where it went and draws the
62
+ strip it uncovered, so its widgets are not drawn again
63
+ ([extending.md](extending.md#an-element-that-covers-its-box), issue #681) —
64
+ the five steps in the table would be 24 kB drawn.
65
+
66
+ What react-x11 pays instead is **latency sensitivity**. Bandwidth is not the
67
+ scarce resource on a link like this — round trips are. A request that waits
68
+ for a reply stalls the pipeline for a full RTT, so on a 60 ms link every
69
+ reply is 60 ms of nothing.
70
+
71
+ The last two columns are how that is watched, and the difference between
72
+ them is the whole point. `replies` counts requests that carry an answer;
73
+ `stalls` counts the ones whose reply arrived with **nothing pipelined behind
74
+ them** — the request was the last thing sent, so the connection really did
75
+ sit idle for a round trip. A pipelined reply costs a reply and no time: the
76
+ mount's 32 are almost all `InternAtom`, fired off together while the window
77
+ is built, and it stalls on none of them. (The bench's own `settle()` fences
78
+ are a fixed floor in that column, so read growth rather than the absolute
79
+ number.)
80
+
81
+ So the rule — "avoid round trips", rule 4 in
82
+ [AGENTS.md](../AGENTS.md#protocol-efficiency) — is really "avoid _blocking_
83
+ round trips", which is why atoms, geometry and glyph pages are cached rather
84
+ than re-asked, and why `stalls` is the column to watch on a slow link.
85
+ `npm run bench -- --hotspots` attributes them, request by request.
86
+
87
+ Rules of thumb, from the shape of the protocol rather than from a benchmark
88
+ we have not run:
89
+
90
+ - **Under ~20 ms RTT** (a LAN, a nearby VPS): indistinguishable from local.
91
+ - **20–80 ms** (a continent away): typing and scrolling stay responsive;
92
+ window mapping and font setup have a visible beat.
93
+ - **Above ~150 ms** or on a lossy link: X11's chattiness starts to show, and
94
+ a pixel-streaming protocol designed for high latency will feel better.
95
+ This is not a react-x11 limitation, it is X11's, and it is the honest place
96
+ to say so.
97
+
98
+ `unverified:` those bands are reasoning from the request/reply counts above,
99
+ not measurements over a shaped link. Measuring them properly means `tc
100
+ netem` between two hosts; if you do it, the numbers belong in this table.
101
+
102
+ ## `ssh -X` and `ssh -Y`
103
+
104
+ `-X` runs your app as an **untrusted** X client. `-Y` runs it trusted, which
105
+ turns the restrictions off. They are not "the one that works" and "the one
106
+ that does not":
107
+
108
+ | | `-X` (untrusted) | `-Y` (trusted) |
109
+ | ---------------------------------- | ---------------- | -------------- |
110
+ | read other windows' pixels | no | yes |
111
+ | grab the keyboard, record input | no | yes |
112
+ | take the clipboard from other apps | restricted | yes |
113
+ | your app's own windows | fine | fine |
114
+
115
+ **Prefer `-X`.** react-x11 should work under it — it draws into windows it
116
+ created, which is what untrusted mode is for. If something does not, that is
117
+ a bug worth filing rather than a reason to reach for `-Y`. Most toolkits'
118
+ documentation says "use `-Y` if `-X` misbehaves"; that advice hands every
119
+ program on the remote host the ability to read your screen and log your
120
+ keystrokes.
121
+
122
+ ### The failure that looks like a crash
123
+
124
+ An untrusted `-X` cookie **expires**. OpenSSH's `ForwardX11Timeout` defaults
125
+ to 20 minutes; after that the connection is refused and your app dies
126
+ mid-session with a connection reset — no warning, no message that mentions
127
+ X11, and it happens long enough after startup that nobody connects the two.
128
+
129
+ ```sh
130
+ # in ~/.ssh/config
131
+ Host buildbox
132
+ ForwardX11 yes
133
+ ForwardX11Trusted no
134
+ ForwardX11Timeout 8h # or 0 for no expiry
135
+ ```
136
+
137
+ If a long-running app dies after roughly twenty minutes and
138
+ `onDisconnect(reason)` reports `'error'`, this is the first thing to check.
139
+ react-x11 tells you it happened, and nothing more —
140
+ [a reconnect is not a reconnect](README.md):
141
+ every window id, pixmap, glyph set and font died with the connection.
142
+
143
+ ## `DISPLAY=host:0` — and why not
144
+
145
+ The other way to reach a remote display is to point `DISPLAY` straight at it
146
+ and open port 6000. Do not:
147
+
148
+ - The connection is **plaintext**. Every keystroke, every glyph you draw and
149
+ the authentication cookie itself cross the network in the clear.
150
+ - Making it work usually means `xhost +`, which turns off access control for
151
+ _everyone_, or `xhost +host`, which trusts every user on that host.
152
+ - Most X servers ship with TCP listening disabled now, so it takes
153
+ deliberate work to open the hole.
154
+
155
+ `ssh -X` gives you the same thing, encrypted, authenticated per-session, and
156
+ with no listening port. Use it. If you genuinely need a raw display
157
+ connection — a lab network, a kiosk — carry the cookie explicitly with
158
+ `xauth` and never with `xhost` (see [security.md](security.md)).
159
+
160
+ ## The other X servers
161
+
162
+ "An X server" is broader than "a Linux desktop":
163
+
164
+ | where | what runs |
165
+ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
166
+ | Linux desktop | X.Org, or **Xwayland** — see below |
167
+ | macOS | [XQuartz](https://www.xquartz.org). `ssh -X` from Terminal starts it for you |
168
+ | Windows | VcXsrv, Xming, or an X server inside WSLg |
169
+ | headless CI | `Xvfb :99 -screen 0 1280x800x24` |
170
+ | a disposable screen | `Xephyr :10 -screen 1200x800` — a window on your desktop that _is_ a display. What the [window-manager example](../examples/README.md) needs |
171
+ | a session you can reattach to | `Xvnc` / TigerVNC, or **Xpra**, which is X11-native and can detach and reattach a running app |
172
+ | no display at all | node-x11's in-process server — [testing.md](testing.md) |
173
+
174
+ **Xwayland: yes.** On a Wayland desktop, X11 clients run under Xwayland, and
175
+ react-x11 is an ordinary X11 client there. Everything in this documentation
176
+ applies. Some window-manager interactions are thinner (Xwayland brokers what
177
+ the compositor is willing to expose), and `WM_TRANSIENT_FOR` and the
178
+ `_NET_WM_STATE` work are among the things that survive the trip — see
179
+ [elements.md](elements.md#transientfor--a-window-that-belongs-to-another).
180
+
181
+ **Native Wayland: not remotely**, and the reason is not effort. Wayland has
182
+ no drawing protocol. A Wayland client allocates a buffer, renders pixels into
183
+ it _itself_, and hands the compositor a handle — there is nothing to send over
184
+ a wire, and no server-side rendering to ask for. The entire premise of this
185
+ page, that drawing crosses the connection, does not exist there. The Wayland
186
+ backend (`backend: 'wayland'`, [wayland-backend.md](wayland-backend.md)) is
187
+ therefore a different program under the same API — it rasterizes locally —
188
+ which is why it is opt-in only and never what `'auto'` picks: on Linux the
189
+ default stays X11, and with it everything on this page.
190
+
191
+ **XQuartz notes**, since it is the most common macOS path:
192
+
193
+ - The window manager is quartz-wm, which has the thinnest EWMH support in
194
+ the stack. It does not advertise `_NET_WM_STATE_ABOVE`, so `alwaysOnTop`
195
+ falls back to Apple-WM window levels ([elements.md](elements.md)).
196
+ - Put `/opt/X11/bin` first on `PATH`. Font family resolution shells out to
197
+ `fc-match`, and Homebrew's fontconfig — which is usually found first —
198
+ ships no macOS system-font aliases, so `sans-serif` resolves to a CJK
199
+ face. Issue [#86](https://github.com/sidorares/react-x11/issues/86).
200
+
201
+ ## Making it feel better
202
+
203
+ Everything the renderer does to be cheap locally pays double here, because
204
+ each saved request is a saved trip across the link:
205
+
206
+ - Keep repaints **bounded**. `REACT_X11_DEBUG_PAINT=full` warns, with a
207
+ reason and a stack, on any silent full-window repaint —
208
+ [debugging.md](debugging.md). A full repaint is the difference between
209
+ 4 kB and 40 on every frame.
210
+ - `REACT_X11_TRACE=summary` prints the request and byte counts for a live
211
+ app, so you can see what a given interaction actually costs on the wire
212
+ before blaming the link.
213
+ - `ssh -C` compresses. X11 traffic is highly compressible — repeated
214
+ drawing requests, text, glyph data — and on a slow link it is usually
215
+ worth it.
216
+ - Prefer one long-lived connection to many short ones: connection setup is
217
+ several round trips of its own, plus the font and atom warm-up.
218
+ - **Drags across applications are round-trip heavy**, because finding the
219
+ foreign window under the pointer means asking the server, per pointer
220
+ position. Dragging _within_ the app costs nothing extra — it never
221
+ touches the wire — and a drop target that answers with `e.freeze()` stops
222
+ the source asking while the pointer stays inside it. See
223
+ [drag-and-drop.md](drag-and-drop.md).
package/docs/scale.md ADDED
@@ -0,0 +1,339 @@
1
+ # Display scale
2
+
3
+ Every length in react-x11 is a **logical pixel**: a unit sized so that
4
+ `fontSize: 14` is comfortably readable at the distance the display is
5
+ actually viewed from. On the panels of the last decade one logical pixel is
6
+ two device pixels — or 1.5, or 1.25 — and the renderer multiplies exactly
7
+ once, on the way to the server. An app says nothing and gets a UI the same
8
+ physical size on a 2013 monitor and a retina laptop.
9
+
10
+ ```jsx
11
+ const root = await createRoot(); // scale: 'auto' is the default
12
+ const root = await createRoot({ scale: 2 }); // pin it
13
+ REACT_X11_SCALE=1.5 node app.js // the user outranks everybody
14
+ ```
15
+
16
+ ```jsx
17
+ import { useScale } from 'react-x11';
18
+ const scale = useScale(); // 1, 2, 1.5... static for the life of the root
19
+ ```
20
+
21
+ ## What is logical and what is device
22
+
23
+ **Logical — everything application code touches:**
24
+
25
+ - every style length (`width`, `padding`, `fontSize`, `borderRadius`, the
26
+ lengths inside `boxShadow` and gradient stops, `hitSlop`…)
27
+ - window geometry props (`width`, `height`, `x`, `y`, `minWidth`…) and the
28
+ `onResize` payload
29
+ - event coordinates: `ev.x`/`ev.y`/`ev.localX`/`ev.localY`, wheel
30
+ `deltaX`/`deltaY`, drag `screenX`/`screenY`
31
+ - `getClientRects()`, `measure()`, `useScreens()` rects,
32
+ `anchorRect`/`centerRect`/`anchorArea`
33
+ - the scroll surface: `scrollTo()`/`scrollBy()` arguments, `onScroll` and
34
+ `onViewport` payloads
35
+ - the theme: `DefaultTheme.fontSize` is 14 and stays 14
36
+
37
+ **Device — the renderer's internals and the deliberate escape hatches:**
38
+
39
+ - `node.abs`, yoga's computed layout, everything painted
40
+ - `node.scrollX`/`scrollY`/`contentWidth`/`contentHeight` read directly off
41
+ a ref (positions in the rendered surface; use the `onScroll` payload for
42
+ logical math)
43
+ - `<canvas onDraw>`: the browser's own canvas contract — the box arrives in
44
+ device pixels and the payload carries `scale`, so detail is drawn on the
45
+ real grid. Deliberately **not** a `ctx.scale()`: ntk positions glyphs
46
+ through the transform but sizes them from the font, so a scaled transform
47
+ would move an app's `fillText` without growing it — and it would knock
48
+ every fill off ntk's server-side fast paths besides.
49
+ - a registered element's `paint()` and `hitTest()` (see
50
+ [extending.md](extending.md)): `this.style` and `this.abs` arrive already
51
+ device, and so are `paintDamage()`, the rects handed to `invalidate` and
52
+ `scrollContents` and the rects of an a11y scene; synthetic events are
53
+ logical, and `this.scale` is on every node for the constants that never
54
+ pass through a style and the event coordinate on its way to `abs`.
55
+
56
+ Fonts are the point of the whole exercise: a `fontSize: 14` at scale 2 is
57
+ shaped, measured and rasterized at 28px. Text on a retina panel is _sharper_,
58
+ not bigger-blurrier — the same reason every native toolkit renders this way
59
+ instead of transform-scaling a 1x picture.
60
+
61
+ "Logical" is per node rather than per window as soon as a `scale` prop is in
62
+ the tree: everything in the first list is divided by the scale of the node
63
+ it is about, which is the display's until an enclosing `scale` says
64
+ otherwise. See "A subtree of its own" below.
65
+
66
+ ## How `'auto'` decides
67
+
68
+ X11 never grew a scale protocol, so the answer is scattered across four
69
+ generations of convention. The ladder consults each rung only when the ones
70
+ above said nothing, ordered by "who is closest to a human having decided":
71
+
72
+ 1. **Environment** — `REACT_X11_SCALE` (ours, wins over everything including
73
+ an explicit `scale:` number — it is the accessibility escape hatch), then
74
+ `GDK_SCALE`, then `QT_SCALE_FACTOR`. The user already told their other
75
+ toolkits; an app of ours on the same desktop should agree.
76
+ 2. **XSETTINGS** — `Gdk/WindowScalingFactor` when it is 2+, then `Xft/DPI`
77
+ (in 1024ths of a dpi on the wire; a daemon writing plain dpi is read
78
+ right too). This is what the desktop's own settings dialog writes and
79
+ what every GTK app on the screen is already obeying.
80
+ 3. **`RESOURCE_MANAGER`** — `Xft.dpi`, the `xrdb` convention winit,
81
+ Chromium and every terminal emulator read.
82
+ 4. **RandR millimetres** — the panel's physical size against its pixel
83
+ size, run per output, under mutter's viewing-distance model: the DPI
84
+ that counts as "1x" is 135 under a 20″ diagonal (a panel in your lap at
85
+ ~50cm) and 110 over it (across a desk). Snapped to quarter steps in
86
+ [1, 3]. For calibration: a 27″ 2560×1440 desk monitor computes 109dpi →
87
+ 1x; a 27″ 4K computes 163dpi → 1.5x; a 16″ MacBook panel computes
88
+ 255dpi → 2x, matching macOS.
89
+ 5. **The resolution class** — when the millimetres are absent or caught
90
+ lying, the pixel grid itself is the last signal: nothing ships as a 1x
91
+ panel with a grid ≥3000 wide or taller than 1600 on its short side, so
92
+ those are called 2x and everything else 1x. That takes in the 2560×1664
93
+ and 2880×1800 lids, and a VM window over any retina panel (a UTM window
94
+ on a 14″ MacBook is 2488×1668). Only the confident call is made without
95
+ physical data — 2560×1440 and 2560×1600 stay 1x on purpose, because
96
+ they are the commonest 1x desk monitors there are and only millimetres
97
+ could tell them from a 13″ retina lid.
98
+ 6. **1.**
99
+
100
+ Two readings deserve their asterisks. **96 is not an answer**: `Xft.dpi: 96`
101
+ and `Gdk/WindowScalingFactor: 1` are what daemons publish when nobody ever
102
+ opened the dialog (xfsettingsd writes both unconditionally), so they fall
103
+ through to the hardware instead of pinning an unconfigured 4K laptop to
104
+ microscopic. And **millimetres are audited before they are believed**
105
+ (`classifyMm`): zeroes, aspect-ratios-as-sizes (16×9 "millimetres"),
106
+ physical/pixel aspect disagreement, and — the one that motivated all of
107
+ this — virtual machines, detected by EDID vendor and model (`RHT` / "QEMU
108
+ Monitor", VMware, VirtualBox, Parallels, Hyper-V) and by connector name
109
+ (`Virtual-*`, `qxl`, …). `XWAYLAND*` outputs skip the hardware rung too:
110
+ the compositor owns scaling there and publishes its decision through rung
111
+ 2, in both of its Xwayland modes.
112
+
113
+ ### Servers that are not describing hardware
114
+
115
+ Rungs 4–5 read a panel, so they are skipped where the connection has no
116
+ panel to describe. Two cases, both of them servers people really run:
117
+
118
+ - **XQuartz**, detected by the `Apple-WM` extension. macOS composes the
119
+ desktop in _points_ and hands X the point space, already normalised for
120
+ density: a 16″ retina lid arrives as 1728×1080, a 1x monitor beside it as
121
+ 2560×1403, and the window server scales what it is given onto whichever
122
+ panel the window lands on. Inferring a factor here doubles a size macOS
123
+ is about to double again, so the ladder answers 1x with the source
124
+ `xquartz` — the same exemption `XWAYLAND` gets, for the same reason.
125
+ - **A single synthetic output covering every monitor.** XQuartz, Xvfb,
126
+ x11vnc and TigerVNC, Xephyr and pre-RandR drivers answer the output walk
127
+ with one output — usually named `default`, always without millimetres —
128
+ whose CRTC is the _union_ of the desktop. Two 2560×1440 monitors union to
129
+ 5120×1440, which rung 5 reads as a retina panel; the machine this was
130
+ found on unioned a retina lid and two 1x monitors into 5120×2520 and drew
131
+ every widget at double size. Xinerama reports those heads separately, so
132
+ more heads than outputs retires rung 5 (`isUnionOutput`). Rung 4 survives
133
+ it — credible millimetres describe real glass however the pixels were
134
+ divided up, which is what an `xrandr --setmonitor` split of one ultrawide
135
+ is.
136
+
137
+ Both guards sit _below_ the configured rungs: `REACT_X11_SCALE`,
138
+ `GDK_SCALE` and a desktop's own XSETTINGS still win, because a person who
139
+ typed a factor outranks the platform.
140
+
141
+ ### The machine this was built against
142
+
143
+ A 16″ MacBook panel handed 1:1 to a UTM Linux guest (XFCE, X11) defeats
144
+ rungs 1–4 simultaneously, which is why the ladder has five:
145
+
146
+ ```
147
+ core screen 3456×2168 px, "914×573mm" → synthesized to exactly 96dpi
148
+ XSETTINGS Gdk/WindowScalingFactor 1, Xft/DPI 98304 (= 96·1024)
149
+ RESOURCE_MANAGER Xft.dpi: 96 → the never-configured default
150
+ RandR Virtual-1 "870×550mm" → a 40″ panel that is 16″
151
+ EDID vendor RHT, model "QEMU Monitor" → the lie, signed
152
+ verdict 2x via the resolution class → correct
153
+ ```
154
+
155
+ Every millimetre in that transcript is invented — QEMU fabricates a size
156
+ that makes the maths land on ~100dpi — and every configured source is an
157
+ untouched default. Run `node scripts/scale-probe.mjs` against any display
158
+ to see the same table for it, or `REACT_X11_DEBUG_SCALE=1` in an app to
159
+ watch the ladder resolve.
160
+
161
+ ## On Wayland
162
+
163
+ A compositor has a scale protocol, and its answer is the display scale:
164
+ fractional-scale-v1 where it is offered (1.25, 1.5…), else
165
+ `wl_surface.preferred_buffer_scale`, else the integer `wl_output.scale` of
166
+ the output a window is on. Buffers are drawn at that density, so text is
167
+ as sharp at 1.5 as at 2. Two things still outrank it, as on X11:
168
+
169
+ - **`REACT_X11_SCALE`, then `createRoot({ scale })`** pin the factor,
170
+ absolutely: device (buffer) pixels per logical pixel. On a compositor at
171
+ 1x, `REACT_X11_SCALE=2` is a window twice the size drawn at full
172
+ resolution, not a small one stretched.
173
+ - **The resolution class, when the compositor says 1.** A VM window over a
174
+ retina panel is the case: QEMU's EDID describes the guest's screen as a
175
+ ~100dpi monitor, mutter believes it and runs at 100%, and every app laid
176
+ out at the compositor's word comes up half the size it is on the host.
177
+ When every output is at 1x and the largest has no credible millimetres
178
+ (`classifyMm`) but a retina-class grid, the layout is 2x — what the X11
179
+ ladder answers for the same screen through Xwayland. A compositor that
180
+ scales any output has decided for itself (133% on that same VM is
181
+ someone's choice), and credible millimetres were the compositor's own
182
+ evidence, so both keep their answer.
183
+
184
+ The frame a window draws for itself follows the compositor, not the
185
+ layout: it is drawn at the desktop's scale, beside every other window's.
186
+ `REACT_X11_DEBUG_SCALE=1` prints which of these answered.
187
+
188
+ ## Several monitors
189
+
190
+ Rungs 4–5 are computed for every connected output, because a desktop with a
191
+ retina lid and an office monitor genuinely has two answers. The **root's**
192
+ scale — the one layout and paint use — is the primary output's (the largest,
193
+ where nothing is flagged primary), matching what GNOME does on X11: one
194
+ scale for the session, chosen for the display you called primary.
195
+
196
+ The per-output answers ride on `useScreens()` — every entry carries its own
197
+ `scale` — so an app that places windows can do better, and a window can be
198
+ pinned with `createRoot({ scale })` per root. What this deliberately does
199
+ not do is re-scale a window as it is dragged between mismatched monitors:
200
+ X11 has one coordinate space and no per-window scale protocol, so that move
201
+ is a resize the window manager fights mid-drag; Qt is the one toolkit that
202
+ tries it, and "chosen at creation, static after" is the behaviour of
203
+ everything else on this window system.
204
+
205
+ Static-at-startup is the other honest limitation: a mid-session change of
206
+ `Xft.dpi` or a monitor swap does not re-scale running windows. Rare enough
207
+ that GTK3 apps mostly share it; restart the app.
208
+
209
+ ## A subtree of its own
210
+
211
+ The display scale is one number for the whole window. `scale` on an element
212
+ is a second factor for one **subtree** of it:
213
+
214
+ ```jsx
215
+ <box scale={zoom} style={{ width: w / zoom, height: h / zoom }}>
216
+ <NodeBody /> // written in plain logical pixels, unaware of the zoom
217
+ </box>
218
+ ```
219
+
220
+ The semantics are CSS's `zoom`, not a transform:
221
+
222
+ - A node's **effective scale** is its parent's times its own `scale` prop,
223
+ so nested props multiply and the display scale is the floor they multiply.
224
+ `node.scale` is that number, and every path that already asked for it —
225
+ `scaleResolvedStyle` at the end of the style funnel, the caret and
226
+ scrollbar constants, shadows and gradients, a registered element's own
227
+ `paint()` — follows with no second mechanism.
228
+ - **The node's own style scales too.** `scale={2}` on a box with
229
+ `padding: 8` gets 16 device pixels of padding, not 8 with bigger children
230
+ inside it. That is what makes the caller's job the one-liner above: divide
231
+ the size you want the box to occupy by the zoom, and write everything
232
+ inside it at 1x.
233
+ - **Text is shaped at the size it is drawn at.** A `fontSize: 14` inside
234
+ `scale={2}` is shaped, measured and rasterized at 28 — the same thing the
235
+ display scale does, for the same reason. A zoomed-in graph is not a
236
+ stretched screenshot of a zoomed-out one.
237
+ - **Yoga lays out the scaled numbers** like any others, so a zoomed subtree
238
+ wraps, flexes and measures its content floors at the size it is drawn.
239
+
240
+ What it is for: an element that owns a viewport — a graph pane, a canvas, a
241
+ map — and mounts application content inside it. The pane draws its own
242
+ world, and the React subtree beside it has to follow the same zoom;
243
+ `ctx.scale()` cannot do that here (see "Why multiply values instead of
244
+ transforming the context" below) and a layer transform on the Cocoa
245
+ presenter would draw a zoomed body whose hit testing still lived at the
246
+ unzoomed rects.
247
+
248
+ ### What a boundary costs
249
+
250
+ **Events are in the target's unit.** `ev.x`, `ev.y`, `localX`/`localY` and
251
+ `getClientRects()` on a node inside a zoomed subtree are all divided by
252
+ _that node's_ scale, so they agree with each other and with the styles it
253
+ was written in — a handler inside the subtree needs to know nothing. The
254
+ trade-off is CSS `zoom`'s: a handler on an **unzoomed ancestor** reads a
255
+ coordinate in the zoomed descendant's unit, because the target is what
256
+ decides. When a pane needs its own coordinates for a hit that landed inside
257
+ the content it hosts, `ev.nativeEvent` is the way back — device pixels of
258
+ the window, which `/ node.scale` converts to whichever unit is wanted.
259
+ `screenX`/`screenY` are unaffected: they are desktop coordinates, and the
260
+ desktop has one scale.
261
+
262
+ A wheel notch scrolls `48` of the target's logical pixels, so content under
263
+ the pointer travels the distance the zoom says it should.
264
+
265
+ **A real X window is its own root.** `<window>` and `<popup>` take no
266
+ `scale` prop and ignore an enclosing one: their geometry is the server's, a
267
+ window manager sees no zoom, and a menu opened from a zoomed card should
268
+ come up at the size the rest of the app's menus are. `<glarea>` and
269
+ `<foreign>` do inherit — their boxes are laid out by the parent like any
270
+ other child's.
271
+
272
+ **Quantise a gesture-driven zoom.** Every distinct factor is a distinct
273
+ font size to shape and cache, so a wheel handler feeding the raw accumulator
274
+ into `scale` makes a new set of glyphs per frame. Round to a step — 5%, or
275
+ a ladder of preset zooms — and the cache does its job.
276
+
277
+ ## Prior art, and where this sits
278
+
279
+ | Toolkit | X11 sources, in order | Notes |
280
+ | -------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
281
+ | GTK 3/4 | `GDK_SCALE` env; XSETTINGS `Gdk/WindowScalingFactor` | integer only; fonts via `Xft/DPI` |
282
+ | Qt 5/6 | `QT_SCALE_FACTOR`, `QT_SCREEN_SCALE_FACTORS`; per-screen logical DPI (`Xft.dpi`), physical DPI from RandR | the only per-screen attempt on X11 |
283
+ | winit | `WINIT_X11_SCALE_FACTOR`; `Xft.dpi`; RandR mm | mm path famously burned by 1×1mm and VM EDIDs ([winit#1983](https://github.com/rust-windowing/winit/issues/1983)) |
284
+ | Chromium/Electron | `Xft.dpi` via XSETTINGS then xrdb; `GDK_SCALE` | never trusts RandR mm |
285
+ | SDL 2/3 | `Xft.dpi` only | 1x on every unconfigured desktop |
286
+ | mutter (the config UI's default) | per-monitor mm under the 135/110-dpi viewing-distance model | the model rung 4 adopts, constants and all |
287
+
288
+ The composition is the contribution: desktop configuration first (rungs
289
+ 1–3, which is where Chromium/SDL stop — correct on configured desktops,
290
+ tiny on unconfigured HiDPI ones), mutter's perceptual model where the
291
+ hardware is honest (which is where winit's mm math stops — right until an
292
+ EDID lies), and the resolution class where it is not (which nobody else
293
+ has, and which is the only rung that survives a VM).
294
+
295
+ ## Design notes
296
+
297
+ **Why multiply values instead of transforming the context.** Two hard
298
+ reasons, both ntk's. Its 2d context batches solid fills into server-side
299
+ `Render.FillRectangles` and takes similar fast paths for strokes, rounded
300
+ rects and glyph runs — all gated on an identity transform, so a frame-wide
301
+ `ctx.scale(2,2)` would rasterize every box in the UI as polygons. And its
302
+ text model sizes glyphs from the font while only _positioning_ them through
303
+ the transform, so scaled-transform text comes out moved but not grown.
304
+ Multiplying at the style funnel instead means paint code, damage rects, the
305
+ scroll blit and the paint cache all keep working in integer device pixels
306
+ with the transforms they always had. (`paintcache.js` even guards against
307
+ the other choice: a non-identity CTM disables it entirely.)
308
+
309
+ **Where the multiply lives.** One place for styles —
310
+ `scaleResolvedStyle()`, at the end of the style funnel in `_syncStyle`, so
311
+ state blocks, size queries and the flex shorthand all still think in the
312
+ logical pixels the app wrote. One place for the theme's font size — the
313
+ root branch of `inheritedTextStyle`, because descendants inherit an
314
+ already-resolved size and any second multiply would compound. One place for
315
+ window geometry (`scaleWindowGeometry`), one per string-parsed decoration
316
+ (`shadowSpecs`/`gradientSpec`, whose parses are memoized on the raw string
317
+ so the scale rides into the memo key), and division at each app-facing
318
+ egress (`SyntheticEvent`, `getClientRects`, `onScroll`, `useScreens`…).
319
+ `yoga` needs no `pointScaleFactor` games: layout runs directly in device
320
+ pixels and its ordinary whole-pixel rounding is what keeps a 1-logical-px
321
+ border crisp at 1.5x.
322
+
323
+ **Fractional scales** work end to end (`1.25`, `1.5`, quarter steps from
324
+ the ladder), with the usual X11 caveat: a 1-logical-px hairline at 1.25x is
325
+ 1 or 2 device pixels depending on where it lands. Integer scales have no
326
+ such seams.
327
+
328
+ **What tests see.** The headless harnesses resolve to exactly 1 — a mock
329
+ app has no display to ask — so every geometry assertion in the suite means
330
+ its numbers literally. `createRoot({ app, scale: 2 })` opts a test in, and
331
+ so does `renderX11(element, { scale: 2 })` from `react-x11/test`;
332
+ `test/scale-render.test.js` is the boundary contract pinned as tests.
333
+ `setScaleForTests()` (from `src/scale.js`) pins a factor without a
334
+ connection.
335
+
336
+ **Known costs.** The paint cache's per-item budget is device pixels, so at
337
+ 2x a widget occupies 4× the budget it did — icons and checkboxes still fit
338
+ comfortably. Damage rects, protocol traffic and backing stores scale the
339
+ same way any HiDPI surface does.