react-x11 2.51.1 → 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 +9 -2
  68. package/src/bus.js +39 -26
  69. package/src/cli/create.d.ts +43 -0
  70. package/src/cli/create.js +305 -0
  71. package/src/cli/env.js +484 -0
  72. package/src/cli/index.js +29 -2
  73. package/src/cli/templates/AGENTS.template.md +195 -0
  74. package/src/cli/templates/DESIGN.template.md +269 -0
  75. package/src/desktopintegration.js +2 -1
  76. package/src/desktopsettings.js +193 -24
  77. package/src/filedialog.js +3 -3
  78. package/src/keyboardstate.js +28 -0
  79. package/src/node.d.ts +10 -10
  80. package/src/portal.js +31 -0
  81. package/src/screencolor.js +5 -10
  82. package/src/types/system.d.ts +8 -6
  83. package/src/wayland/app.js +59 -0
  84. package/src/wayland/clipboard.js +14 -7
  85. package/src/wayland/connection.js +3 -0
  86. package/src/wayland/context2d.js +129 -10
  87. package/src/wayland/protocols/index.json +6 -0
  88. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  89. package/src/wayland/seat.js +28 -1
  90. package/src/wayland/swapchain.js +23 -0
  91. package/src/wayland/xkb.js +137 -0
@@ -0,0 +1,416 @@
1
+ # System appearance
2
+
3
+ What the desktop looks like — light or dark, the accent colour, contrast, and
4
+ whether the user asked for less motion — as four values an app renders from.
5
+
6
+ ```jsx
7
+ import { useSystemAppearance } from 'react-x11';
8
+
9
+ const { colorScheme, accent, contrast, reducedMotion } = useSystemAppearance();
10
+ ```
11
+
12
+ **Most apps never need it.** react-x11's built-in palette already follows the
13
+ desktop, so an app that says nothing about colour is dark on a dark desktop
14
+ and light on a light one, and its buttons, tabs and menu highlights are the
15
+ desktop's accent where the desktop has one — see
16
+ [following the desktop](#following-the-desktop) below. Reach for the hook when
17
+ you want the values themselves: reduced motion, the accent as a colour for
18
+ something that is not a widget, or a design decision that is not a palette.
19
+
20
+ ## The four values
21
+
22
+ | | |
23
+ | --------------- | ---------------------------------------------------------------------------------------------- |
24
+ | `colorScheme` | `'light'`, `'dark'` or `'no-preference'` |
25
+ | `accent` | `'#ed5b00'`, or **null** |
26
+ | `accentText` | the ink the desktop writes on its accent — `'#ffffff'` on macOS — or **null** |
27
+ | `selection` | the fill under a selected menu or list row — a darker cut of the accent on macOS — or **null** |
28
+ | `palette` | the desktop's whole palette as tokens, on macOS — or **null** |
29
+ | `contrast` | `'normal'` or `'high'` |
30
+ | `reducedMotion` | `true` when the user asked for less animation |
31
+ | `source` | which rung answered — `'portal'`, `'xsettings'`, `'macos'`, `'windows'`, `'cache'`, or null |
32
+
33
+ Two of these are easy to get wrong.
34
+
35
+ **`'no-preference'` means _use your own default_**, not "use light". It is
36
+ what a desktop says when it has no opinion, and an app whose own design is
37
+ dark should stay dark there.
38
+
39
+ **`accent` is null far more often than it is not.** Most portal backends
40
+ implement `color-scheme` and nothing else, and XSETTINGS has no accent key at
41
+ all. Fall back to your own brand colour rather than to grey — an app that goes
42
+ colourless on a desktop that simply did not answer the question looks broken.
43
+
44
+ ```jsx
45
+ const { accent } = useSystemAppearance();
46
+ <box style={{ borderColor: accent ?? '#2980b9' }} />;
47
+ ```
48
+
49
+ `accentText` is the ink the desktop puts on that fill, and it comes from the
50
+ one source that has an opinion: AppKit writes white on every accent a Mac
51
+ offers, including the orange and the pink a contrast ratio would put dark
52
+ letters on, and every native control does. The portal names a fill and nothing
53
+ about what goes on it, so there it is null and the palette picks the legible
54
+ ink by contrast, as it does for any theme that names a fill and stops there.
55
+
56
+ `selection` is the third colour a desktop names, and macOS is again the one
57
+ that does: a selected menu row or list row is filled not with the accent but
58
+ with `selectedContentBackgroundColor`, a darker cut of it — same hue,
59
+ lightness 0.54 → 0.40 in dark and 0.45 in light for the orange, hand-tuned per
60
+ accent rather than computed from it. The built-in palette uses it as
61
+ `hoverBackground`; a menu lit in the raw accent beside a native one reads as
62
+ too bright.
63
+
64
+ ## The desktop's palette
65
+
66
+ On macOS the rung reads the rest as well: the semantic colours AppKit paints
67
+ its own windows and controls with, in the appearance the desktop is in. They
68
+ arrive as `palette`, already in react-x11's vocabulary, and the built-in
69
+ palette merges them over the scheme's own the way a `<ThemeProvider>` value
70
+ merges — so an app that says nothing about colour comes up in the desktop's
71
+ greys, inks and status colours, and follows every retune macOS ships.
72
+
73
+ Two settings drive it, and they are not one colour. **Accent colour** is an
74
+ index into a table of eight, each entry hand-tuned per appearance: the fill,
75
+ the darker cut under a selected row, the focus ring and the pressed steps are
76
+ all separate table entries, not a formula. **Highlight colour** is a free
77
+ sRGB triple that only text selection and the caret read. Everything else is
78
+ fixed per appearance.
79
+
80
+ | token | AppKit source | how |
81
+ | ------------------------------ | ---------------------------------------------------------- | ------------------------------------------------------- |
82
+ | `background` | `windowBackgroundColor` | as is: `#ececec` light, `#323232` dark |
83
+ | `surface` | `controlBackgroundColor` | as is: white, `#1e1e1e` |
84
+ | `surfaceHover` | the second `alternatingContentBackgroundColors` | composited over `surface` |
85
+ | `text`, `textMuted` | `labelColor`, `secondaryLabelColor` | black or white at 85% and 50–55%, over the ground |
86
+ | `border` | `separatorColor` | 10% ink over the ground |
87
+ | `focusRing`, `borderFocus` | `keyboardFocusIndicatorColor` | the 50% ring over the ground |
88
+ | `track` | `unemphasizedSelectedContentBackgroundColor` | as is |
89
+ | `accent`, `accentText` | `controlAccentColor`, `alternateSelectedControlTextColor` | as is; the ink is white for all eight |
90
+ | `accentHover`, `accentActive` | the accent with the pressed and deep-pressed effects | as is — darker in light, lighter in dark |
91
+ | `hoverBackground`, `hoverText` | `selectedContentBackgroundColor`, the same ink | as is |
92
+ | `selection` | `selectedTextBackgroundColor` | the Highlight colour; dark is each channel less 116/255 |
93
+ | `caret` | `textInsertionPointColor` | the accent of the Highlight colour's name |
94
+ | `link` | `linkColor` | as is, never the accent |
95
+ | `danger`, `dangerHover` | `systemRedColor`, pressed | as is |
96
+ | `success`, `warning`, `info` | `systemGreenColor`, `systemOrangeColor`, `systemBlueColor` | orange for warning: yellow fails as letters |
97
+
98
+ AppKit's inks are translucent and every token here is a colour, so each is
99
+ flattened over the window ground on the way in. The status inks and the
100
+ pressed steps a table does not name come from `resolveTheme`, as for any
101
+ theme. What this cannot see is desktop tinting: a dark window on macOS is
102
+ `#323232` with a little of the wallpaper in it, and the wallpaper is not in
103
+ any colour AppKit will hand out.
104
+
105
+ The portal names none of this, so on Linux the built-in palette stands and
106
+ only the accent is taken.
107
+
108
+ ## The first frame
109
+
110
+ None of this can be known synchronously — the answer is a D-Bus call away —
111
+ and an app that renders light and then switches to dark a moment later has a
112
+ visible flash.
113
+
114
+ So **the answer from last time is on disk**, in
115
+ `$XDG_CACHE_HOME/react-x11/appearance.json`, and it is read synchronously
116
+ before the first render — 0.1 ms, no D-Bus, no await. None of these values
117
+ changes more than a few times in a machine's life, so the remembered answer is
118
+ almost always the right one; the ladder revalidates it in the background and
119
+ replaces it if it moved. Stale-while-revalidate, where the stale value is
120
+ nearly always the fresh one.
121
+
122
+ `source` is how you tell them apart:
123
+
124
+ ```
125
+ run 1 {colorScheme: 'no-preference', accent: null, source: null} ← nothing remembered
126
+ {colorScheme: 'dark', accent: '#ed5b00', source: 'portal'} ← 78 ms later
127
+
128
+ run 2 {colorScheme: 'dark', accent: '#ed5b00', source: 'cache'} ← 0.1 ms, first render
129
+ {colorScheme: 'dark', accent: '#ed5b00', source: 'portal'} ← revalidated
130
+
131
+ (`accentText`, `selection`, `contrast` and `reducedMotion` travel with them.)
132
+ ```
133
+
134
+ Revalidation costs one re-render even when nothing moved, because `source`
135
+ itself changed. That is deliberate: a `source` that stayed `'cache'` after the
136
+ desktop had actually answered would be worth less than the render it saved.
137
+
138
+ That leaves the very first launch on a machine, where there is nothing to
139
+ remember. An app that must be exact rather than probably right waits:
140
+
141
+ ```jsx
142
+ import { createRoot, systemAppearance } from 'react-x11';
143
+
144
+ const [root] = await Promise.all([createRoot(), systemAppearance()]);
145
+ root.render(<App />); // useSystemAppearance() is already correct
146
+ ```
147
+
148
+ `systemAppearance()` is the imperative twin of the hook, and never rejects.
149
+
150
+ **Why it is not simply part of `createRoot()`.** Measured cold on a GNOME
151
+ session: `createRoot()` alone 85 ms, `createRoot()` with a concurrent portal
152
+ probe 124 ms. `dbus-native`'s import is CPU-bound, so it does not hide behind
153
+ ntk's startup — and an app that never asks what colour the desktop is should
154
+ not pay 40 ms to find out.
155
+
156
+ `REACT_X11_NO_APPEARANCE_CACHE=1` turns the file off for a process that must
157
+ not touch the disk.
158
+
159
+ ## Following the desktop
160
+
161
+ **Nothing.** That is the whole section.
162
+
163
+ react-x11's built-in palette _is_ the desktop's: an app that says nothing
164
+ about colour is dark on a dark desktop and light on a light one, the way a
165
+ GTK or Qt app is, and where the desktop reports an accent — macOS always,
166
+ GNOME 47+ and KDE through the portal — `$accent` and the family around it
167
+ (`accentHover`, the pressed step, `hoverBackground`, the focus ring) are that
168
+ colour, and `hoverBackground` is the desktop's own selection shade where it
169
+ names one. On macOS the whole palette is the desktop's — see
170
+ [the desktop's palette](#the-desktops-palette). The window background, the widgets and every `$token` in a style all
171
+ come from it.
172
+
173
+ ```jsx
174
+ // this app follows the desktop
175
+ function App() {
176
+ return (
177
+ <window width={400} height={200}>
178
+ <box style={{ flexGrow: 1, padding: 16, backgroundColor: '$background' }}>
179
+ <text style={{ color: '$text' }}>Hello</text>
180
+ <Button primary label="Save" />
181
+ </box>
182
+ </window>
183
+ );
184
+ }
185
+ ```
186
+
187
+ A `<window>` with no `backgroundColor` takes the palette's, so even that line
188
+ is optional.
189
+
190
+ ### Overriding
191
+
192
+ `<ThemeProvider value={…}>` layers your palette **over the scheme in force**,
193
+ so it names what your app changes and everything else keeps following:
194
+
195
+ ```jsx
196
+ <ThemeProvider value={{ accent: '#e17055', radius: 10 }}>
197
+ ```
198
+
199
+ That app has its own accent and corner radius on both a light and a dark
200
+ desktop, and never wrote a second palette.
201
+
202
+ `colorScheme` pins a subtree, for a design that only works one way or a
203
+ preference the app owns rather than the desktop:
204
+
205
+ ```jsx
206
+ <ThemeProvider value={brand} colorScheme="light"> // never follows
207
+ <ThemeProvider value={brand} colorScheme={settings.theme}> // the app decides
208
+ ```
209
+
210
+ **Pinning is also the complete opt-out.** A pinned provider subscribes to
211
+ nothing and the widgets under it read the palette it published, so an app that
212
+ says `colorScheme="light"` once at the top never has react-x11 ask the desktop
213
+ anything.
214
+
215
+ `dark` is the other half — a palette layered on only when the scheme in force
216
+ is dark, for a design whose two schemes are not one recolour of the other:
217
+
218
+ ```jsx
219
+ <ThemeProvider value={{ background: '#fffdf7' }} dark={{ background: '#141210' }}>
220
+ ```
221
+
222
+ **The accent is followed on the same terms as the scheme.** The reason is
223
+ the one that decided the scheme: an app that says nothing is asking to look
224
+ like it belongs, and on a desktop that has an accent every control beside it is
225
+ already that colour — on the Cocoa backend the app's own native checkboxes and
226
+ default buttons are drawn by AppKit in the user's accent, so a `<Tabs>`
227
+ indicator that stayed blue next to them looked like a bug. A brand keeps its
228
+ own colour by naming it; a following provider's `value` wins over the desktop
229
+ token for token, so name the family rather than the one colour:
230
+
231
+ ```jsx
232
+ <ThemeProvider value={{ accent: '#e17055', accentHover: '#c0563a', hoverBackground: '#e17055' }}>
233
+ ```
234
+
235
+ And a pinned `colorScheme` follows nothing, the accent included. What does
236
+ **not** move with the accent is `info`: a note is blue everywhere, under a
237
+ green accent as under an orange one, and `success`/`warning`/`danger` say what
238
+ they say in any accent.
239
+
240
+ ### Resizing
241
+
242
+ The colour a window is _painted_ is only half of it. Dragging a window larger
243
+ exposes area before the app can possibly have drawn it, and something has to
244
+ be in that area meanwhile — so a window that never says what flashes a default
245
+ on every drag of the corner, which on a dark palette is a bright rectangle.
246
+
247
+ There are two copies of that colour, because a window here is double-buffered:
248
+ the X **attribute** the server paints exposed area with, and the colour ntk
249
+ clears the part of its backing store that a grow adds. react-x11 sets both —
250
+ to the window's own `backgroundColor`, or the palette's — and keeps them in
251
+ step when either moves. Nothing to do; it is worth knowing only because they
252
+ are the pieces of the palette that live outside this renderer.
253
+
254
+ Needs **ntk >= 6.6.1**, where `setBackgroundPixel` sets the pair together.
255
+ Before that the backing store cleared to the screen's white whatever the
256
+ window said, so enlarging a dark window flashed a white strip — and it
257
+ survived, because a grow inside the pixmap's headroom reallocates nothing and
258
+ so nothing damages it.
259
+
260
+ ### Testing
261
+
262
+ `react-x11/test` **pins the colour scheme to light**, and that is not a
263
+ convenience: with a palette that follows the desktop, an unpinned suite renders
264
+ in whatever colours the machine running it happens to be in, so a pixel
265
+ assertion would be green on a light desktop and red on a dark one with nothing
266
+ in the test to say so.
267
+
268
+ ```js
269
+ await renderX11(<App />, { fonts: FONTS }); // light
270
+ await renderX11(<App />, { fonts: FONTS, colorScheme: 'dark' }); // dark
271
+ await renderX11(<App />, { fonts: FONTS, colorScheme: 'system' }); // the real one
272
+ ```
273
+
274
+ `createMockApp()` pins the same way. `'system'` is for a test that is _about_
275
+ what the desktop reports.
276
+
277
+ The other values pin too, from the first render or under a mounted tree —
278
+ which is how a component that follows `reducedMotion`, `contrast` or the
279
+ accent is tested, change included. A pinned appearance reports
280
+ `source: 'test'`.
281
+
282
+ ```js
283
+ await renderX11(<App />, { appearance: { reducedMotion: true } });
284
+ await setAppearance({ reducedMotion: false }); // re-renders, repaints
285
+ ```
286
+
287
+ See [testing](testing.md#the-desktops-appearance) for the rules.
288
+
289
+ ## Reduced motion
290
+
291
+ `reducedMotion` is reported, not applied. Nothing in the renderer switches
292
+ animation off behind your back — an app with `transition: 150` keeps it until
293
+ the app decides otherwise:
294
+
295
+ ```jsx
296
+ const { reducedMotion } = useSystemAppearance();
297
+ <box style={{ transition: reducedMotion ? 0 : 150, backgroundColor: fill }} />;
298
+ ```
299
+
300
+ `transition: 0` is already how "no animation" is spelled, so there is nothing
301
+ new to learn. See [styling](styling.md) for what a transition covers.
302
+
303
+ ## The ladder
304
+
305
+ There is no cross-toolkit palette protocol. Nothing on a Linux desktop lets
306
+ one toolkit ask another what colour a window background is; what exists is a
307
+ shared theme _name_, and exactly four standardised appearance _values_. Those
308
+ four are what this reads, from the best source the machine has:
309
+
310
+ 1. **`org.freedesktop.portal.Settings`** — the real contract, and the only
311
+ source with an accent colour. `ReadAll(['org.freedesktop.appearance'])`,
312
+ live over `SettingChanged`. libadwaita, Qt 6.5+, Firefox and Electron all
313
+ read this one.
314
+ 2. **macOS** — `NSUserDefaults` and `NSWorkspace` through one long-lived
315
+ `osascript` child, and only where the _process_ is running on a Mac. It
316
+ comes before XSETTINGS on purpose: on a Mac, the Mac's own preference is
317
+ the one the user set, and an XSETTINGS daemon there is something they
318
+ installed by hand. It is in practice the only source on a Mac, on either
319
+ backend — a stock XQuartz has no portal, no XSETTINGS manager and an
320
+ unset `RESOURCE_MANAGER`. A _Linux_ process drawing to an XQuartz display
321
+ never reaches this rung at all, which is correct: it cannot read that
322
+ Mac's defaults.
323
+ 3. **Windows** — answered by the win32 backend itself: the Personalize key
324
+ for light or dark, DWM for the accent, `SystemParametersInfo` for contrast
325
+ and reduced motion, live over `WM_SETTINGCHANGE` and its siblings. Before
326
+ XSETTINGS for the same reason macOS is, and asked of the app rather than
327
+ of the platform, so only a tree on the win32 backend reaches it.
328
+ 4. **XSETTINGS** — pre-D-Bus, X11-only. `Net/ThemeName` is a _name_, so "is
329
+ this dark" comes down to trusting the `-dark` suffix — which is exactly
330
+ what the portal was invented to replace, so it is a fallback and never a
331
+ correction. Live over `PropertyNotify`.
332
+
333
+ `RESOURCE_MANAGER` is not a rung. It is where `Xft.dpi`, `Xft.rgba` and
334
+ `Xcursor.*` live — font and cursor rendering — and there has never been an X
335
+ resource for colour scheme, accent or contrast.
336
+
337
+ **The first rung that answers owns all four values.** The rungs disagree:
338
+ measured on one GNOME session, at one moment, the portal reported
339
+ `reduced-motion: 0` while GNOME's own `enable-animations` was `false`. Taking
340
+ the best-answered field from each would describe a desktop that does not
341
+ exist.
342
+
343
+ Nothing here holds the process open. The `SettingChanged` subscription leaves
344
+ the match rule on the shared connection and releases its reference, so the
345
+ socket goes back to `unref()`d: an app whose windows have closed still exits,
346
+ and an app with a window on screen is awake anyway and gets the signal. The
347
+ macOS child is spawned `unref()`d and killed on exit.
348
+
349
+ ### Not climbing it at all
350
+
351
+ ```jsx
352
+ await createRoot({ desktop: { appearance: false } });
353
+ ```
354
+
355
+ For an app that owns its palette, an embedder that follows the desktop its own
356
+ way, and a test that needs the same answer on every machine. The ladder does
357
+ not run and **the remembered answer is not read either**: `colorScheme` stays
358
+ `'no-preference'` with `source: null`, which means use your own default. See
359
+ [desktop.md](desktop.md#turning-the-desktop-off) — `desktop: false` turns this
360
+ off along with the other two integrations that talk to the session bus.
361
+
362
+ ## What each rung can actually answer
363
+
364
+ | | portal | macOS | XSETTINGS |
365
+ | --------------- | ------------------------------------ | ----- | ------------------- |
366
+ | `colorScheme` | yes | yes | from the theme name |
367
+ | `accent` | yes, where the backend implements it | yes | **no such key** |
368
+ | `accentText` | no — the palette picks by contrast | yes | no |
369
+ | `selection` | no — the accent itself | yes | no |
370
+ | `palette` | no | yes | no |
371
+ | `contrast` | yes | yes | from the theme name |
372
+ | `reducedMotion` | version 2 of the interface | yes | rarely — see below |
373
+
374
+ `Gtk/EnableAnimations` is in GTK's key list but a settings daemon need not
375
+ export it, and gnome-settings-daemon does not — 53 settings on the session
376
+ this was written against, and it is not among them. So on GNOME the XSETTINGS
377
+ rung reports no reduced motion rather than inventing an answer.
378
+
379
+ ## Notes on the sources
380
+
381
+ **1 is dark and 2 is light** in the portal's `color-scheme`. The ordering
382
+ reads backwards, and getting it wrong inverts the appearance of every desktop
383
+ that expressed a preference — which is precisely the set of machines that
384
+ care.
385
+
386
+ **The accent colour is a `(ddd)` of sRGB floats in [0, 1]**, and "unset" is
387
+ spelled as values outside that range. It is converted to a `'#rrggbb'` string
388
+ here, because every style in this renderer takes a CSS colour and
389
+ `rgb(0.93, 0.36, 0)` is black.
390
+
391
+ **The subscription goes on before the read.** A change landing between the
392
+ read and the match rule is lost and nothing corrects it — the app stays stale
393
+ for its whole lifetime. It is the same shape as the portal `Request` race in
394
+ [the file dialog](filedialog.md).
395
+
396
+ **On macOS the frameworks are read, not `defaults`.** Three of the four
397
+ plausible `defaults` keys — `AppleHighlightColor`, `AppleAccentColor`,
398
+ `com.apple.universalaccess increaseContrast` — do not exist until the user
399
+ changes that setting, so "key not found" is the normal answer rather than the
400
+ error case, and `AppleAccentColor` is an index into a table that has to be
401
+ maintained by hand. `NSColor.controlAccentColor` is the colour itself, with
402
+ Multicolor already resolved, `alternateSelectedControlTextColor` is the ink
403
+ AppKit writes on it, `selectedContentBackgroundColor` is the shade under a
404
+ selected row (both resolved in the appearance the desktop is in, since a
405
+ dynamic colour in a bare `osascript` resolves as Aqua otherwise), and
406
+ `NSWorkspace` answers the two accessibility flags directly.
407
+
408
+ **A process reads the colours once.** `controlAccentColor` is resolved on
409
+ first use and cached for the life of the process: after the user picks another
410
+ accent, the same process still answers the old one, with or without an
411
+ `NSApplication`. So the watcher prints its values once and, on the change
412
+ notifications, exits — and react-x11 spawns another, whose first read is
413
+ fresh. A watcher that re-read in place re-announced the same values, and a
414
+ running app never saw the change. The Cocoa backend
415
+ reads the same child: it is one source for both backends, and the accent it
416
+ reports is the one AppKit draws the native bezels in.