react-x11 2.6.1 → 2.8.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 (59) hide show
  1. package/README.md +5 -3
  2. package/package.json +10 -3
  3. package/src/activate.js +12 -0
  4. package/src/anchor.js +6 -0
  5. package/src/appearance.js +351 -28
  6. package/src/appearancehooks.js +5 -2
  7. package/src/application.js +41 -0
  8. package/src/cocoa/app.js +367 -3
  9. package/src/cocoa/bezels.js +51 -1
  10. package/src/cocoa/dnd.js +358 -0
  11. package/src/cocoa/dock.js +39 -0
  12. package/src/cocoa/filepanels.js +155 -0
  13. package/src/cocoa/fonts.js +93 -2
  14. package/src/cocoa/globalmenu.js +41 -33
  15. package/src/cocoa/notifications.js +244 -0
  16. package/src/cocoa/permissions.js +74 -0
  17. package/src/cocoa/presenter.js +274 -33
  18. package/src/cocoa/promotion.js +708 -0
  19. package/src/cocoa/statusitem.js +112 -0
  20. package/src/cocoa/window.js +113 -4
  21. package/src/components/Button.js +20 -1
  22. package/src/components/Checkbox.js +17 -2
  23. package/src/components/Menu.js +108 -38
  24. package/src/components/Radio.js +17 -2
  25. package/src/components/Select.js +159 -27
  26. package/src/components/Switch.js +8 -1
  27. package/src/components/native.js +99 -0
  28. package/src/components/theme.js +37 -20
  29. package/src/desktopsettings.js +34 -2
  30. package/src/dnd.js +137 -11
  31. package/src/errors.js +6 -3
  32. package/src/filedialog.js +81 -16
  33. package/src/index.d.ts +29 -2
  34. package/src/index.js +17 -0
  35. package/src/launcher.js +170 -0
  36. package/src/launcherhooks.js +81 -0
  37. package/src/nodes.js +604 -37
  38. package/src/notificationhooks.js +56 -0
  39. package/src/notifications.js +558 -0
  40. package/src/palette.js +144 -8
  41. package/src/permissionhooks.js +89 -0
  42. package/src/permissions.js +196 -0
  43. package/src/style.d.ts +10 -4
  44. package/src/style.js +1 -0
  45. package/src/styles.js +161 -15
  46. package/src/textselection.js +1 -4
  47. package/src/trayhooks.js +90 -0
  48. package/src/types/appearance.d.ts +24 -0
  49. package/src/types/components.d.ts +10 -0
  50. package/src/types/elements.d.ts +14 -0
  51. package/src/types/events.d.ts +14 -0
  52. package/src/types/filedialog.d.ts +18 -7
  53. package/src/types/launcher.d.ts +43 -0
  54. package/src/types/notifications.d.ts +113 -0
  55. package/src/types/permissions.d.ts +100 -0
  56. package/src/types/style.d.ts +30 -2
  57. package/src/types/system.d.ts +5 -3
  58. package/src/types/tray.d.ts +54 -0
  59. package/src/windowid.js +23 -0
package/README.md CHANGED
@@ -100,9 +100,9 @@ three:
100
100
  | ------------------------------------------------- | ------------------------------------------------------- |
101
101
  | ![dashboard](docs/img/dashboard.png) | ![tasks](docs/img/tasks.png) |
102
102
 
103
- | `examples/form.jsx` — textinput + Select | the open Select menu (a real `<popup>` window) |
104
- | ---------------------------------------- | ---------------------------------------------- |
105
- | ![form](docs/img/form.png) | ![select menu](docs/img/select-menu.png) |
103
+ | `examples/form/index.jsx` — textinput + Select | the open Select menu (a real `<popup>` window) |
104
+ | ---------------------------------------------- | ---------------------------------------------- |
105
+ | ![form](docs/img/form.png) | ![select menu](docs/img/select-menu.png) |
106
106
 
107
107
  `examples/viewer3d.jsx` — a model viewer over **indirect GLX**: the GL
108
108
  protocol sent over the X connection, geometry compiled into a display list,
@@ -270,12 +270,14 @@ explore them:
270
270
  npm run examples:simple # hello world (JSX via tsx)
271
271
  npm run examples:app # the showcase: Tabs + SplitPane hosting the rest
272
272
  npm run examples:theming # three themes x light/dark, and a size query
273
+ npm run examples:container-queries # one card, two panes: '@container', and a named one
273
274
  npm run examples:simple-nojsx # the same, plain node — no build step
274
275
  npm run examples:xeyes # canvas drawing + hooks
275
276
  npm run examples:dashboard # context theming, custom hooks, components
276
277
  npm run examples:tasks # useReducer, textinput, scrolling
277
278
  npm run examples:menu # right-click context menu via <popup>
278
279
  npm run examples:transparent # rounded translucent <popup transparent>
280
+ npm run examples:animation # transitions and loops, and which backend runs them
279
281
  npm run examples:form # <textinput> + Select dropdowns
280
282
  npm run examples:datepicker # Calendar/DatePicker: ranges, blocked days, events
281
283
  npm run examples:password # PasswordInput: the scribble mask, and a custom one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.6.1",
3
+ "version": "2.8.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -14,7 +14,9 @@
14
14
  "format:check": "prettier --check .",
15
15
  "examples:app": "tsx examples/app.jsx",
16
16
  "examples:theming": "tsx examples/theming.jsx",
17
+ "examples:container-queries": "tsx examples/container-queries.jsx",
17
18
  "examples:appearance": "tsx examples/appearance.jsx",
19
+ "examples:animation": "tsx examples/animation.jsx",
18
20
  "examples:simple": "tsx examples/simple.jsx",
19
21
  "examples:simple-nojsx": "node examples/simple-nojsx.js",
20
22
  "examples:xeyes": "tsx examples/xeyes.jsx",
@@ -22,8 +24,11 @@
22
24
  "examples:tasks": "tsx examples/tasks.jsx",
23
25
  "examples:tasks:hot": "node --enable-source-maps --import react-x11/refresh/register examples/tasks-hot.jsx",
24
26
  "examples:menu": "tsx examples/menu.jsx",
27
+ "examples:notify": "tsx examples/notify.jsx",
25
28
  "examples:monitor": "tsx examples/monitor.jsx",
26
29
  "examples:attention": "tsx examples/attention.jsx",
30
+ "examples:badge": "tsx examples/badge.jsx",
31
+ "examples:tray": "tsx examples/tray.jsx",
27
32
  "examples:tooltips": "tsx examples/tooltips.jsx",
28
33
  "examples:chat": "tsx examples/chat.jsx",
29
34
  "examples:clipboard": "tsx examples/clipboard.jsx",
@@ -36,7 +41,8 @@
36
41
  "devtools:proxy": "node scripts/devtools-proxy.mjs",
37
42
  "examples:configurator": "tsx examples/configurator/index.jsx",
38
43
  "examples:fonts": "tsx examples/fonts.jsx",
39
- "examples:form": "tsx examples/form.jsx",
44
+ "examples:form": "tsx examples/form/index.jsx",
45
+ "examples:form:app": "sh examples/form/make-app.sh && open examples/form/build/Guestbook.app",
40
46
  "examples:rules": "tsx examples/rules.jsx",
41
47
  "examples:selection": "tsx examples/selection.jsx",
42
48
  "examples:settings": "tsx examples/settings.jsx",
@@ -86,12 +92,13 @@
86
92
  "node": ">=20.19"
87
93
  },
88
94
  "dependencies": {
95
+ "linebreak": "^1.1.0",
89
96
  "ntk": "^8.7.0",
90
97
  "react-reconciler": "^0.33.0",
91
98
  "yoga-layout": "^3.2.1"
92
99
  },
93
100
  "optionalDependencies": {
94
- "@windowkit/appkit": "^0.4.0",
101
+ "@windowkit/appkit": "^0.5.1",
95
102
  "dbus-native": "^0.15.1",
96
103
  "x11-dri": "^0.7.0"
97
104
  },
package/src/activate.js CHANGED
@@ -143,6 +143,18 @@ export function activateWindow(target, { timestamp, source } = {}) {
143
143
  const app = node?.app ?? node?.root?.app ?? wnd?.app ?? soleApp();
144
144
 
145
145
  const chosen = wnd ?? (windowIdOf(node) === null ? inferWindow(app) : null);
146
+
147
+ // The cocoa backend has no window manager and no `_NET_ACTIVE_WINDOW` to
148
+ // send — the raise is `NSApp.activate` plus ordering the window front,
149
+ // which the app object performs (src/cocoa/app.js `raiseWindow`).
150
+ // Feature-detected so the X11 path below is untouched, and so this stops
151
+ // reporting the false success the no-op X stub would: on cocoa `app.X` is
152
+ // a shim whose `InternAtom`/`SendClientMessage` go nowhere yet still let
153
+ // the code below return `true`.
154
+ if (app && typeof app.raiseWindow === 'function') {
155
+ return app.raiseWindow(chosen);
156
+ }
157
+
146
158
  const xid = windowIdOf(chosen) ?? windowIdOf(node);
147
159
  const X = ntkWindowOf(chosen)?.X ?? wnd?.X ?? app?.X;
148
160
  const root = X?.display?.screen?.[0]?.root;
package/src/anchor.js CHANGED
@@ -209,6 +209,10 @@ export function anchorRect(node, options = {}) {
209
209
  offset: logicalOffset = 2,
210
210
  at,
211
211
  alignTo,
212
+ // A popup that covers its own anchor — a native popup button's menu,
213
+ // which opens with the chosen row over the control — has no other side
214
+ // to flip to: it is clamped into the screen instead, as AppKit clamps.
215
+ flip = true,
212
216
  direction = node.direction,
213
217
  } = options;
214
218
  const alignOffset = logicalAlignOffset * s;
@@ -278,6 +282,7 @@ export function anchorRect(node, options = {}) {
278
282
  const below = ay + ah + offset;
279
283
  const above = ay - height - offset;
280
284
  if (
285
+ flip &&
281
286
  side === 'bottom' &&
282
287
  bottom != null &&
283
288
  below + height > bottom &&
@@ -285,6 +290,7 @@ export function anchorRect(node, options = {}) {
285
290
  ) {
286
291
  side = 'top';
287
292
  } else if (
293
+ flip &&
288
294
  side === 'top' &&
289
295
  above < top &&
290
296
  (bottom == null || below + height <= bottom)
package/src/appearance.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // What the desktop looks like, as four values an app can render from:
2
- // light or dark, the accent colour, contrast, and whether the user asked for
3
- // less motion.
2
+ // light or dark, the accent colour (with the ink the desktop puts on it,
3
+ // where it names one), contrast, and whether the user asked for less motion.
4
4
  //
5
5
  // ## Why this is a ladder and not a call
6
6
  //
@@ -77,6 +77,9 @@ const APPEARANCE_NS = 'org.freedesktop.appearance';
77
77
  const NOTHING = Object.freeze({
78
78
  colorScheme: 'no-preference',
79
79
  accent: null,
80
+ accentText: null,
81
+ selection: null,
82
+ palette: null,
80
83
  contrast: 'normal',
81
84
  reducedMotion: false,
82
85
  source: null,
@@ -106,9 +109,22 @@ const watchers = new Set();
106
109
  // Publishing
107
110
  // --------------------------------------------------------------------------
108
111
 
112
+ /** Two palettes with the same tokens — or both absent. */
113
+ function samePalette(a, b) {
114
+ if (a === b) return true;
115
+ if (!a || !b) return false;
116
+ const keys = Object.keys(a);
117
+ return (
118
+ keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k])
119
+ );
120
+ }
121
+
109
122
  const SAME = (a, b) =>
110
123
  a.colorScheme === b.colorScheme &&
111
124
  a.accent === b.accent &&
125
+ a.accentText === b.accentText &&
126
+ a.selection === b.selection &&
127
+ samePalette(a.palette, b.palette) &&
112
128
  a.contrast === b.contrast &&
113
129
  a.reducedMotion === b.reducedMotion &&
114
130
  a.source === b.source;
@@ -215,16 +231,40 @@ const SCHEMES = new Set(['light', 'dark', 'no-preference']);
215
231
  * colour is the one thing worth being sure of.
216
232
  */
217
233
  function sanitize(saved) {
234
+ // A string, checked as one: `RegExp.test` coerces, and `['#ed5b00']`
235
+ // would pass the pattern and then reach a style as an array.
236
+ const hex = (value) =>
237
+ typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) ? value : null;
218
238
  return {
219
239
  colorScheme: SCHEMES.has(saved?.colorScheme)
220
240
  ? saved.colorScheme
221
241
  : 'no-preference',
222
- accent: /^#[0-9a-f]{6}$/i.test(saved?.accent) ? saved.accent : null,
242
+ accent: hex(saved?.accent),
243
+ accentText: hex(saved?.accentText),
244
+ selection: hex(saved?.selection),
245
+ palette: sanitizePalette(saved?.palette, hex),
223
246
  contrast: saved?.contrast === 'high' ? 'high' : 'normal',
224
247
  reducedMotion: saved?.reducedMotion === true,
225
248
  };
226
249
  }
227
250
 
251
+ /**
252
+ * A remembered palette is taken whole or not at all: every token named in
253
+ * `PALETTE_TOKENS` must be a colour, and nothing else is kept. A palette with
254
+ * a hole would merge over the built-in one and paint a built-in colour next
255
+ * to the desktop's, which is the one look this whole thing exists to avoid.
256
+ */
257
+ function sanitizePalette(saved, hex) {
258
+ if (!saved || typeof saved !== 'object') return null;
259
+ const palette = {};
260
+ for (const token of PALETTE_TOKENS) {
261
+ const value = hex(saved[token]);
262
+ if (!value) return null;
263
+ palette[token] = value;
264
+ }
265
+ return Object.freeze(palette);
266
+ }
267
+
228
268
  /**
229
269
  * Seed the snapshot from disk. Once, synchronously, on the first read —
230
270
  * never at import, so a process that does not ask what colour the desktop is
@@ -279,6 +319,9 @@ function save(values) {
279
319
  v: CACHE_VERSION,
280
320
  colorScheme: values.colorScheme,
281
321
  accent: values.accent,
322
+ accentText: values.accentText,
323
+ selection: values.selection,
324
+ palette: values.palette,
282
325
  contrast: values.contrast,
283
326
  reducedMotion: values.reducedMotion,
284
327
  }),
@@ -343,6 +386,12 @@ export function fromPortal(ns = {}) {
343
386
  return {
344
387
  colorScheme: schemeFromPortal(ns['color-scheme']),
345
388
  accent: accentFromPortal(ns['accent-color']),
389
+ // The portal names the fill and nothing about what goes on it, nor a
390
+ // second shade for a selected row; the palette picks the legible ink
391
+ // itself (`resolveTheme`) and highlights with the accent.
392
+ accentText: null,
393
+ selection: null,
394
+ palette: null,
346
395
  contrast: ns.contrast === 1 ? 'high' : 'normal',
347
396
  // version 2 of the interface; on version 1 the key is simply absent and
348
397
  // "no" is the right answer
@@ -473,6 +522,9 @@ export function fromXSettings(map) {
473
522
  colorScheme: dark ? 'dark' : theme ? 'light' : 'no-preference',
474
523
  // XSETTINGS has no accent colour. Not "none set" — no such key exists.
475
524
  accent: null,
525
+ accentText: null,
526
+ selection: null,
527
+ palette: null,
476
528
  contrast: high ? 'high' : 'normal',
477
529
  reducedMotion: animations === 0,
478
530
  };
@@ -509,42 +561,259 @@ async function xsettingsRung(app) {
509
561
  * already resolved, and `NSWorkspace` answers the two accessibility flags
510
562
  * directly.
511
563
  *
564
+ * **The ink is read too.** `alternateSelectedControlTextColor` is what AppKit
565
+ * writes on a control filled with the accent, and it is not what a contrast
566
+ * ratio would choose: white on the orange accent is 2.6:1, and every native
567
+ * control does it anyway. A palette that follows the desktop's fill and
568
+ * picks its own letters for it looks like neither — so the pair travels
569
+ * together, and `accentText` is null from the sources that have no ink to
570
+ * name (the portal), where the palette decides by contrast.
571
+ *
572
+ * **And the selection shade.** A selected menu row or list row on macOS is
573
+ * not filled with the accent but with `selectedContentBackgroundColor`, a
574
+ * darker cut of it — same hue, lightness 0.54 → 0.40 in dark and 0.45 in
575
+ * light for the orange, hand-tuned per accent rather than computed. A menu
576
+ * highlighted in the raw accent beside a native one reads as too bright, so
577
+ * it is read rather than approximated, and is null where nothing names it.
578
+ *
579
+ * **One process reads the colours once.** `controlAccentColor` and the rest
580
+ * are resolved on first use and cached for the life of the process — after
581
+ * the user picks another accent, the same process still answers the old one,
582
+ * with or without an `NSApplication` (measured on macOS 15: the value does
583
+ * not move even after `NSSystemColorsDidChangeNotification`). So the program
584
+ * prints its values once and, on the change notifications, **exits**; the
585
+ * rung spawns another, whose first read is fresh. A watcher that re-read in
586
+ * place re-announced the same values, and nothing downstream ever saw a
587
+ * change — that was the bug.
588
+ *
589
+ * And it leaves when its parent has: eight of these were found on one
590
+ * machine, days old, reparented to launchd by apps that had died without
591
+ * running their exit handler.
592
+ *
512
593
  * Exported so a test can pin the source; it cannot be executed on Linux.
513
594
  */
514
595
  export const MACOS_PROGRAM = `
515
596
  ObjC.import('AppKit');
516
597
  var ud = $.NSUserDefaults.standardUserDefaults;
517
598
  var ws = $.NSWorkspace.sharedWorkspace;
599
+ function srgb(color) {
600
+ var c = color.colorUsingColorSpace($.NSColorSpace.sRGBColorSpace);
601
+ return c.isNil() ? null
602
+ : [c.redComponent, c.greenComponent, c.blueComponent, c.alphaComponent];
603
+ }
518
604
  function read() {
519
605
  var style = ud.stringForKey('AppleInterfaceStyle');
606
+ var dark = !style.isNil() && ObjC.unwrap(style) === 'Dark';
520
607
  var accent = null;
608
+ var accentText = null;
609
+ var selection = null;
521
610
  try {
522
- var c = $.NSColor.controlAccentColor.colorUsingColorSpace(
523
- $.NSColorSpace.sRGBColorSpace);
524
- if (!c.isNil()) accent = [c.redComponent, c.greenComponent, c.blueComponent];
611
+ ObjC.import('stdlib');
612
+ // Dynamic colours resolve in the *current* appearance, which in a bare
613
+ // osascript is Aqua whatever the desktop is in; the ink AppKit puts on
614
+ // a filled control is allowed to differ between the two.
615
+ $.NSAppearance.setCurrentAppearance($.NSAppearance.appearanceNamed(
616
+ dark ? $.NSAppearanceNameDarkAqua : $.NSAppearanceNameAqua));
617
+ accent = srgb($.NSColor.controlAccentColor);
618
+ accentText = srgb($.NSColor.alternateSelectedControlTextColor);
619
+ selection = srgb($.NSColor.selectedContentBackgroundColor);
620
+ } catch (e) {}
621
+ // The rest of the desktop's palette: the semantic colours AppKit paints
622
+ // its own windows and controls with, in this appearance. Alpha is kept
623
+ // and composited on the other side, where the ground is known.
624
+ var colors = null;
625
+ try {
626
+ var C = $.NSColor;
627
+ var A = C.controlAccentColor;
628
+ var R = C.systemRedColor;
629
+ var rows = C.alternatingContentBackgroundColors;
630
+ colors = {
631
+ windowBackground: srgb(C.windowBackgroundColor),
632
+ controlBackground: srgb(C.controlBackgroundColor),
633
+ alternateRow: srgb(rows.objectAtIndex(rows.count > 1 ? 1 : 0)),
634
+ label: srgb(C.labelColor),
635
+ secondaryLabel: srgb(C.secondaryLabelColor),
636
+ separator: srgb(C.separatorColor),
637
+ focus: srgb(C.keyboardFocusIndicatorColor),
638
+ unemphasizedSelection: srgb(C.unemphasizedSelectedContentBackgroundColor),
639
+ accentPressed: srgb(A.colorWithSystemEffect($.NSColorSystemEffectPressed)),
640
+ accentDeepPressed: srgb(A.colorWithSystemEffect($.NSColorSystemEffectDeepPressed)),
641
+ textSelection: srgb(C.selectedTextBackgroundColor),
642
+ caret: srgb(C.textInsertionPointColor),
643
+ link: srgb(C.linkColor),
644
+ red: srgb(R),
645
+ redPressed: srgb(R.colorWithSystemEffect($.NSColorSystemEffectPressed)),
646
+ green: srgb(C.systemGreenColor),
647
+ orange: srgb(C.systemOrangeColor),
648
+ blue: srgb(C.systemBlueColor)
649
+ };
525
650
  } catch (e) {}
526
651
  return JSON.stringify({
527
- dark: !style.isNil() && ObjC.unwrap(style) === 'Dark',
652
+ dark: dark,
528
653
  accent: accent,
654
+ accentText: accentText,
655
+ selection: selection,
656
+ colors: colors,
529
657
  reducedMotion: !!ws.accessibilityDisplayShouldReduceMotion,
530
658
  contrast: !!ws.accessibilityDisplayShouldIncreaseContrast
531
659
  });
532
660
  }
533
- function emit() { console.log(read()); }
534
- emit();
661
+ console.log(read());
662
+ // A change is answered by *leaving*: the parent spawns a fresh process and
663
+ // takes its first line. Reading again here would answer the old colours —
664
+ // see the comment above the program.
665
+ function changed() { $.exit(0); }
535
666
  var dnc = $.NSDistributedNotificationCenter.defaultCenter;
536
667
  ['AppleInterfaceThemeChangedNotification',
537
668
  'AppleColorPreferencesChangedNotification'].forEach(function (name) {
538
669
  dnc.addObserverForNameObjectQueueUsingBlock(
539
- name, $(), $.NSOperationQueue.mainQueue, emit);
670
+ name, $(), $.NSOperationQueue.mainQueue, changed);
540
671
  });
541
672
  ws.notificationCenter.addObserverForNameObjectQueueUsingBlock(
542
673
  'NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification',
543
- $(), $.NSOperationQueue.mainQueue, emit);
674
+ $(), $.NSOperationQueue.mainQueue, changed);
675
+ // An app that dies without its exit handler (a signal, a crash) leaves this
676
+ // process behind, reparented to launchd, for as long as the machine is up.
677
+ ObjC.import('unistd');
678
+ $.NSTimer.scheduledTimerWithTimeIntervalRepeatsBlock(5, true, function () {
679
+ if ($.getppid() === 1) $.exit(0);
680
+ });
544
681
  $.NSRunLoop.currentRunLoop.run();
545
682
  `;
546
683
 
547
- /** One line of the child's output → the four values, or null if it is noise. */
684
+ /**
685
+ * The tokens a desktop palette names — the colour half of the built-in
686
+ * palette, less the inks `resolveTheme` derives by contrast. Both the cache
687
+ * and the macOS parser take a palette whole or not at all, and this is the
688
+ * list "whole" means.
689
+ */
690
+ export const PALETTE_TOKENS = Object.freeze([
691
+ 'background',
692
+ 'surface',
693
+ 'surfaceHover',
694
+ 'text',
695
+ 'textMuted',
696
+ 'border',
697
+ 'borderFocus',
698
+ 'focusRing',
699
+ 'track',
700
+ 'accent',
701
+ 'accentHover',
702
+ 'accentActive',
703
+ 'accentText',
704
+ 'hoverBackground',
705
+ 'hoverText',
706
+ 'selection',
707
+ 'caret',
708
+ 'link',
709
+ 'danger',
710
+ 'dangerHover',
711
+ 'success',
712
+ 'warning',
713
+ 'info',
714
+ ]);
715
+
716
+ /** `[r, g, b]` or `[r, g, b, a]` in [0, 1], or null. */
717
+ function channels(value) {
718
+ if (!Array.isArray(value) || value.length < 3) return null;
719
+ const c = value.slice(0, 4);
720
+ if (c.length === 3) c.push(1);
721
+ return c.every((v) => typeof v === 'number' && v >= 0 && v <= 1) ? c : null;
722
+ }
723
+
724
+ const toHex = (c) =>
725
+ '#' +
726
+ c
727
+ .slice(0, 3)
728
+ .map((v) =>
729
+ Math.round(v * 255)
730
+ .toString(16)
731
+ .padStart(2, '0'),
732
+ )
733
+ .join('');
734
+
735
+ /**
736
+ * AppKit's semantic colours → react-x11's tokens, or null unless every one
737
+ * of them was read.
738
+ *
739
+ * **Alpha is composited here, over the ground it is drawn on.** AppKit's
740
+ * inks are translucent — `labelColor` is black at 85%, `separatorColor` at
741
+ * 10% — and every token in this renderer is a colour, so each is flattened
742
+ * over the window ground. The focus ring is drawn at 50% and gets the same
743
+ * treatment; a ring over a control is a shade off, and no one can see it.
744
+ *
745
+ * The rest is a naming exercise, with two decisions in it. The hover and
746
+ * pressed steps are AppKit's *pressed* and *deep-pressed* effects, because
747
+ * AppKit has no hover state for a filled control and its rollover effect
748
+ * is darker than its pressed one in light mode — a ramp that ran backwards.
749
+ * And `warning` is the system orange, not the yellow: a warning is read as
750
+ * letters too, and yellow on white is not.
751
+ */
752
+ export function paletteFromMacOS(colors) {
753
+ if (!colors || typeof colors !== 'object') return null;
754
+ const read = {};
755
+ for (const name of [
756
+ 'windowBackground',
757
+ 'controlBackground',
758
+ 'alternateRow',
759
+ 'label',
760
+ 'secondaryLabel',
761
+ 'separator',
762
+ 'focus',
763
+ 'unemphasizedSelection',
764
+ 'accentPressed',
765
+ 'accentDeepPressed',
766
+ 'textSelection',
767
+ 'caret',
768
+ 'link',
769
+ 'red',
770
+ 'redPressed',
771
+ 'green',
772
+ 'orange',
773
+ 'blue',
774
+ ]) {
775
+ const c = channels(colors[name]);
776
+ if (!c) return null;
777
+ read[name] = c;
778
+ }
779
+ const over = (c, ground) =>
780
+ toHex(ground.map((g, i) => c[i] * c[3] + g * (1 - c[3])));
781
+ const flat = (c) => toHex(c);
782
+ const ground = read.windowBackground;
783
+ const accent = colors.accent && channels(colors.accent);
784
+ const accentText = colors.accentText && channels(colors.accentText);
785
+ const selection = colors.selection && channels(colors.selection);
786
+ if (!accent || !accentText || !selection) return null;
787
+ const ink = flat(accentText);
788
+ const focus = over(read.focus, ground);
789
+ return Object.freeze({
790
+ background: flat(ground),
791
+ surface: flat(read.controlBackground),
792
+ surfaceHover: over(read.alternateRow, read.controlBackground),
793
+ text: over(read.label, ground),
794
+ textMuted: over(read.secondaryLabel, ground),
795
+ border: over(read.separator, ground),
796
+ borderFocus: focus,
797
+ focusRing: focus,
798
+ track: flat(read.unemphasizedSelection),
799
+ accent: flat(accent),
800
+ accentHover: flat(read.accentPressed),
801
+ accentActive: flat(read.accentDeepPressed),
802
+ accentText: ink,
803
+ hoverBackground: flat(selection),
804
+ hoverText: ink,
805
+ selection: flat(read.textSelection),
806
+ caret: flat(read.caret),
807
+ link: flat(read.link),
808
+ danger: flat(read.red),
809
+ dangerHover: flat(read.redPressed),
810
+ success: flat(read.green),
811
+ warning: flat(read.orange),
812
+ info: flat(read.blue),
813
+ });
814
+ }
815
+
816
+ /** One line of the child's output → the values, or null if it is noise. */
548
817
  export function fromMacOS(line) {
549
818
  let parsed;
550
819
  try {
@@ -553,11 +822,25 @@ export function fromMacOS(line) {
553
822
  return null;
554
823
  }
555
824
  if (!parsed || typeof parsed !== 'object') return null;
825
+ // The palette's accent family is the three values above it, so the parser
826
+ // sees them together
827
+ const colors = parsed.colors
828
+ ? {
829
+ ...parsed.colors,
830
+ accent: parsed.accent,
831
+ accentText: parsed.accentText,
832
+ selection: parsed.selection,
833
+ }
834
+ : null;
556
835
  return {
557
836
  // macOS always has a definite appearance, so an unset AppleInterfaceStyle
558
837
  // is *light* rather than "no preference".
559
838
  colorScheme: parsed.dark ? 'dark' : 'light',
560
839
  accent: accentFromPortal(parsed.accent),
840
+ // The same `(r, g, b)` shape as the accent, by construction above
841
+ accentText: accentFromPortal(parsed.accentText),
842
+ selection: accentFromPortal(parsed.selection),
843
+ palette: paletteFromMacOS(colors),
561
844
  contrast: parsed.contrast ? 'high' : 'normal',
562
845
  reducedMotion: Boolean(parsed.reducedMotion),
563
846
  };
@@ -565,36 +848,61 @@ export function fromMacOS(line) {
565
848
 
566
849
  let child = null;
567
850
 
851
+ /** Set while this process is exiting, so a watcher's death is not answered. */
852
+ let closing = false;
853
+
568
854
  /**
569
- * Spawn the watcher and resolve on its first line or `false` if it dies,
570
- * prints nothing usable, or takes more than a few seconds, any of which mean
571
- * this Mac cannot answer and the ladder is finished.
855
+ * Test seam, not public: what spawns the watcher. A fake here also lets the
856
+ * rung run off a Mac, where the real one cannot, so the respawn is tested on
857
+ * CI rather than on whoever has a Mac.
858
+ */
859
+ let spawnWatcher = null;
860
+ export function _setMacOSSpawnForTests(fn) {
861
+ spawnWatcher = fn;
862
+ }
863
+
864
+ async function spawnProgram() {
865
+ if (spawnWatcher) return spawnWatcher();
866
+ const { spawn } = await import('node:child_process');
867
+ return spawn('osascript', ['-l', 'JavaScript', '-e', MACOS_PROGRAM], {
868
+ stdio: ['ignore', 'pipe', 'pipe'],
869
+ });
870
+ }
871
+
872
+ /** How long after an answered exit the next watcher starts. */
873
+ const RESPAWN_DELAY_MS = 150;
874
+
875
+ /**
876
+ * Run one watcher process and resolve on its first usable line — or `false`
877
+ * if it dies, prints nothing usable, or takes more than a few seconds, any of
878
+ * which mean this Mac cannot answer and the ladder is finished.
879
+ *
880
+ * A watcher that exits *after* answering is a different thing: that is how
881
+ * the program says the desktop changed (see `MACOS_PROGRAM`), and the next
882
+ * one is started to read the new values. One that dies before answering is
883
+ * not replaced — that is osascript failing, and a respawn would loop on it.
572
884
  *
573
885
  * `console.log` in JXA has gone to stderr in some macOS releases and stdout in
574
886
  * others, so both are read. It costs one extra listener to not depend on
575
887
  * which.
576
888
  */
577
- async function macosRung() {
578
- if (process.platform !== 'darwin' || child) return false;
579
- const { spawn } = await import('node:child_process');
580
-
889
+ async function runWatcher() {
581
890
  let proc;
582
891
  try {
583
- proc = spawn('osascript', ['-l', 'JavaScript', '-e', MACOS_PROGRAM], {
584
- stdio: ['ignore', 'pipe', 'pipe'],
585
- });
892
+ proc = await spawnProgram();
586
893
  } catch {
587
894
  return false;
588
895
  }
589
896
  child = proc;
590
897
  // Never a reason for the process to stay alive.
591
- proc.unref();
898
+ proc.unref?.();
592
899
  proc.stdout.unref?.();
593
900
  proc.stderr.unref?.();
594
901
  proc.on('error', () => {});
595
902
 
596
903
  return await new Promise((resolve) => {
597
904
  let settled = false;
905
+ let answered = false;
598
906
  const done = (ok) => {
599
907
  if (settled) return;
600
908
  settled = true;
@@ -618,26 +926,41 @@ async function macosRung() {
618
926
  const values = fromMacOS(line);
619
927
  if (!values) continue;
620
928
  if (settled && owner !== 'macos') return;
929
+ answered = true;
621
930
  publish(values, 'macos');
622
931
  done(true);
623
932
  }
624
933
  };
625
- proc.stdout.setEncoding('utf8');
626
- proc.stderr.setEncoding('utf8');
934
+ proc.stdout.setEncoding?.('utf8');
935
+ proc.stderr.setEncoding?.('utf8');
627
936
  proc.stdout.on('data', onData);
628
937
  proc.stderr.on('data', onData);
629
938
  proc.on('exit', () => {
939
+ if (child === proc) child = null;
630
940
  // Dying after it answered leaves the last value standing, which is more
631
- // useful than reverting to the defaults.
632
- child = null;
941
+ // useful than reverting to the defaults — and, while this rung owns the
942
+ // store, is the cue to read again.
633
943
  done(false);
944
+ if (!answered || owner !== 'macos' || closing || child) return;
945
+ const again = setTimeout(() => {
946
+ if (owner === 'macos' && !closing && !child) void runWatcher();
947
+ }, RESPAWN_DELAY_MS);
948
+ again.unref?.();
634
949
  });
635
950
  });
636
951
  }
637
952
 
953
+ async function macosRung() {
954
+ if ((process.platform !== 'darwin' && !spawnWatcher) || child) return false;
955
+ return runWatcher();
956
+ }
957
+
638
958
  // Killed rather than left behind: `unref()` keeps it from holding *this*
639
959
  // process open, and nothing keeps it from outliving it.
640
- process.on('exit', () => child?.kill());
960
+ process.on('exit', () => {
961
+ closing = true;
962
+ child?.kill();
963
+ });
641
964
 
642
965
  // --------------------------------------------------------------------------
643
966
  // The ladder
@@ -69,6 +69,8 @@ export function useAppearanceWhen(enabled) {
69
69
  * | --- | --- |
70
70
  * | `colorScheme` | `'light'`, `'dark'` or `'no-preference'` |
71
71
  * | `accent` | `'#ed5b00'`, or **null** — most backends do not implement it |
72
+ * | `accentText` | the ink the desktop puts on it — `'#ffffff'` on macOS — or **null** |
73
+ * | `selection` | the fill under a selected menu or list row — a darker cut of the accent on macOS — or **null** |
72
74
  * | `contrast` | `'normal'` or `'high'` |
73
75
  * | `reducedMotion` | `true` when the user asked for less animation |
74
76
  * | `source` | `'portal'`, `'xsettings'`, `'macos'`, `'cache'`, or null |
@@ -88,8 +90,9 @@ export function useAppearanceWhen(enabled) {
88
90
  * const [root] = await Promise.all([createRoot(), systemAppearance()]);
89
91
  * ```
90
92
  *
91
- * See `docs/appearance.md`, and `<ThemeProvider dark={…}>` for the case where
92
- * all you want is for the app to follow the desktop.
93
+ * The built-in palette already follows both the scheme and the accent, so
94
+ * an app that only wants to look like it belongs needs none of this — see
95
+ * `docs/appearance.md`.
93
96
  */
94
97
  export function useSystemAppearance() {
95
98
  return useAppearance(true);