react-x11 2.6.1 → 2.7.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 (58) 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 +317 -1
  9. package/src/cocoa/bezels.js +51 -1
  10. package/src/cocoa/dnd.js +347 -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 +190 -2
  18. package/src/cocoa/statusitem.js +112 -0
  19. package/src/cocoa/window.js +85 -4
  20. package/src/components/Button.js +20 -1
  21. package/src/components/Checkbox.js +17 -2
  22. package/src/components/Menu.js +108 -38
  23. package/src/components/Radio.js +17 -2
  24. package/src/components/Select.js +159 -27
  25. package/src/components/Switch.js +8 -1
  26. package/src/components/native.js +99 -0
  27. package/src/components/theme.js +37 -20
  28. package/src/desktopsettings.js +34 -2
  29. package/src/dnd.js +92 -3
  30. package/src/errors.js +6 -3
  31. package/src/filedialog.js +81 -16
  32. package/src/index.d.ts +17 -1
  33. package/src/index.js +17 -0
  34. package/src/launcher.js +170 -0
  35. package/src/launcherhooks.js +81 -0
  36. package/src/nodes.js +553 -35
  37. package/src/notificationhooks.js +56 -0
  38. package/src/notifications.js +558 -0
  39. package/src/palette.js +144 -8
  40. package/src/permissionhooks.js +89 -0
  41. package/src/permissions.js +196 -0
  42. package/src/style.d.ts +10 -4
  43. package/src/style.js +1 -0
  44. package/src/styles.js +161 -15
  45. package/src/textselection.js +1 -4
  46. package/src/trayhooks.js +90 -0
  47. package/src/types/appearance.d.ts +24 -0
  48. package/src/types/components.d.ts +10 -0
  49. package/src/types/elements.d.ts +14 -0
  50. package/src/types/events.d.ts +14 -0
  51. package/src/types/filedialog.d.ts +18 -7
  52. package/src/types/launcher.d.ts +43 -0
  53. package/src/types/notifications.d.ts +113 -0
  54. package/src/types/permissions.d.ts +100 -0
  55. package/src/types/style.d.ts +30 -2
  56. package/src/types/system.d.ts +5 -3
  57. package/src/types/tray.d.ts +54 -0
  58. package/src/windowid.js +23 -0
@@ -5,7 +5,14 @@
5
5
  import React, { useContext, useEffect, useMemo, useRef } from 'react';
6
6
  import { useAppOrNull } from '../appcontext.js';
7
7
  import { changeEvent } from './change.js';
8
- import { Bezel, bezelNatural, useNativeControls } from './native.js';
8
+ import {
9
+ Bezel,
10
+ CONTROL_FONT_SIZE,
11
+ NO_ROW_RING,
12
+ bezelNatural,
13
+ nativeRingStyle,
14
+ useNativeControls,
15
+ } from './native.js';
9
16
  import { focusRingStyle, labelContent, useControl, useTheme } from './theme.js';
10
17
  import { XK_DOWN, XK_LEFT, XK_RIGHT, XK_UP } from './keys.js';
11
18
 
@@ -67,6 +74,7 @@ export function Radio({ value, children, label, disabled = false, native }) {
67
74
  const {
68
75
  hover,
69
76
  focused,
77
+ focusVisible,
70
78
  pressed,
71
79
  props,
72
80
  style: controlStyle,
@@ -97,6 +105,7 @@ export function Radio({ value, children, label, disabled = false, native }) {
97
105
  style: [
98
106
  controlStyle,
99
107
  { flexDirection: 'row', alignItems: 'center', gap: 8 },
108
+ NO_ROW_RING,
100
109
  ],
101
110
  },
102
111
  h(Bezel, {
@@ -107,11 +116,17 @@ export function Radio({ value, children, label, disabled = false, native }) {
107
116
  style: {
108
117
  width: nat.width,
109
118
  height: nat.height,
110
- ...focusRingStyle(theme, focused),
119
+ // a circle: the ring rounds with it
120
+ ...nativeRingStyle(theme, focusVisible, nat.height / 2),
111
121
  },
112
122
  }),
123
+ // At the control font size, as the cell's own title is: the bezel is
124
+ // designed beside 13pt, and the palette's 14px read a size too large
125
+ // next to it. Centred on the box, which is where AppKit centres it
126
+ // (measured: within a quarter point).
113
127
  labelContent(children ?? label, {
114
128
  color: disabled ? theme.textMuted : theme.text,
129
+ fontSize: CONTROL_FONT_SIZE.regular,
115
130
  }),
116
131
  );
117
132
  }
@@ -9,7 +9,12 @@ import { Icon } from './Icon.js';
9
9
  import {
10
10
  ABS_FILL,
11
11
  Bezel,
12
+ NATIVE_MENU,
13
+ NATIVE_RING,
14
+ TITLE_BASELINE,
12
15
  bezelNatural,
16
+ bezelShadow,
17
+ nativeTitleStyle,
13
18
  pressWash,
14
19
  useNativeControls,
15
20
  } from './native.js';
@@ -52,6 +57,51 @@ const ITEM_PAD_RIGHT = ITEM_PAD;
52
57
  // sized against 14 under a theme that set 18 is a row its own label does not
53
58
  // fit in.
54
59
  const itemHeight = (fontSize) => capBand(fontSize) + ITEM_PAD * 2;
60
+
61
+ /**
62
+ * The menu's geometry: the palette's, or — where the trigger is a native
63
+ * popup bezel — NSMenu's, so the sheet that drops from it is the one the
64
+ * bezel promises. The drawn menu marks the chosen option in bold; a native
65
+ * popup's menu marks it with a check in the column every row reserves, at
66
+ * regular weight, as the bezel's own menu does.
67
+ */
68
+ function menuMetrics(theme, native) {
69
+ if (native) {
70
+ const padLeft = NATIVE_MENU.padLeft + NATIVE_MENU.markColumn;
71
+ const cap = capBand(NATIVE_MENU.fontSize);
72
+ return {
73
+ ...NATIVE_MENU,
74
+ padLeft,
75
+ selectedWeight: 'normal',
76
+ check: true,
77
+ // Where a row's capitals end, from the row's top: the centred band,
78
+ // at the whole pixel. Placed there by the row rather than centred by
79
+ // layout — centring puts the band at 6.5pt, which floors to 6 at 1×
80
+ // and is exact at 2×, and the menu that opens over the trigger is
81
+ // aligned on this number, so it has to be the same one at every
82
+ // scale.
83
+ capsBottom: Math.floor((NATIVE_MENU.row - cap) / 2) + cap,
84
+ // How far the menu hangs past the trigger: left, so the titles line
85
+ // up (the menu's inset less the trigger's); right, past the arrow
86
+ // capsule, read off the screen at 10pt.
87
+ hang: {
88
+ left: MENU_BORDER + NATIVE_MENU.pad + padLeft - TRIGGER_PAD_LEFT,
89
+ right: 10,
90
+ },
91
+ };
92
+ }
93
+ return {
94
+ fontSize: theme.fontSize,
95
+ weight: 'normal',
96
+ selectedWeight: 'bold',
97
+ check: false,
98
+ row: itemHeight(theme.fontSize),
99
+ pad: MENU_PAD,
100
+ padLeft: ITEM_PAD_LEFT,
101
+ padRight: ITEM_PAD_RIGHT,
102
+ radius: rowRadius(theme, MENU_BORDER, MENU_PAD),
103
+ };
104
+ }
55
105
  // The scrolling pane's own padding, and so the inset between the edge
56
106
  // and an option — which is what makes the highlight read as a pill on the
57
107
  // menu rather than a band across it, and what the pill's own corner radius
@@ -63,6 +113,10 @@ const MENU_PAD = 4;
63
113
  // where it meets the desktop behind it, and a theme that draws 2px borders
64
114
  // on its *controls* does not mean a 2px outline around every popup.
65
115
  const MENU_BORDER = 1;
116
+ // Where a native popup bezel's title starts, from the trigger's left edge —
117
+ // and so what the menu's own inset is measured against when it opens over
118
+ // the trigger (`menuAnchorOptions`).
119
+ const TRIGGER_PAD_LEFT = 10;
66
120
  // the scrollbar is drawn *over* the content rather than insetting it
67
121
  // (`nodes.js`, SCROLLBAR_WIDTH), so a menu that scrolls reserves the room
68
122
  const SCROLLBAR_WIDTH = 6;
@@ -88,23 +142,28 @@ function normalizeOption(option) {
88
142
  * than the screen it has to open on — `anchorRect` can slide a popup left to
89
143
  * fit, but nothing can rescue one that is wider than the display.
90
144
  */
91
- function menuWidth(node, options, value, scrolls, fontSize) {
145
+ function menuWidth(node, options, value, scrolls, metrics) {
92
146
  let widest = 0;
93
147
  for (const option of options) {
94
148
  const { width } = measureLabel(node, option.label, {
95
- size: fontSize,
96
- weight: option.value === value ? 'bold' : 'normal',
149
+ size: metrics.fontSize,
150
+ weight: option.value === value ? metrics.selectedWeight : 'normal',
97
151
  });
98
152
  if (width > widest) widest = width;
99
153
  }
100
154
  const chrome =
101
- (MENU_BORDER + MENU_PAD) * 2 +
102
- ITEM_PAD_LEFT +
103
- ITEM_PAD_RIGHT +
155
+ (MENU_BORDER + metrics.pad) * 2 +
156
+ metrics.padLeft +
157
+ metrics.padRight +
104
158
  (scrolls ? SCROLLBAR_WIDTH : 0);
105
159
  // logical throughout: the trigger's rect divided out of device pixels,
106
160
  // the measured labels already logical (measureLabel), the area logical
107
- const trigger = node.abs.width / node.scale;
161
+ // A native popup's menu opens over its control and covers all of it,
162
+ // the arrow capsule included — it hangs out on both sides (`hang`).
163
+ const trigger =
164
+ node.abs.width / node.scale +
165
+ (metrics.hang?.left ?? 0) +
166
+ (metrics.hang?.right ?? 0);
108
167
  const width = Math.max(trigger, Math.ceil(widest) + chrome);
109
168
  const area = anchorArea(node);
110
169
  return area ? Math.min(width, area.width) : width;
@@ -119,6 +178,7 @@ function Option({
119
178
  nodeRef,
120
179
  posinset,
121
180
  setsize,
181
+ metrics,
122
182
  }) {
123
183
  const theme = useTheme();
124
184
  // one highlight, shared by pointer and keyboard: hovering moves the
@@ -135,20 +195,34 @@ function Option({
135
195
  onMouseEnter: () => onHover?.(),
136
196
  onClick: () => onPick(option),
137
197
  style: {
138
- height: itemHeight(theme.fontSize),
139
- justifyContent: 'center',
140
- paddingLeft: ITEM_PAD_LEFT,
141
- paddingRight: ITEM_PAD_RIGHT,
198
+ height: metrics.row,
199
+ justifyContent: metrics.capsBottom ? 'flex-end' : 'center',
200
+ paddingBottom: metrics.capsBottom
201
+ ? metrics.row - metrics.capsBottom
202
+ : 0,
203
+ paddingLeft: metrics.padLeft,
204
+ paddingRight: metrics.padRight,
142
205
  // the menus' pill, for the same reason and by the same rule: an
143
206
  // option list and a menu are one surface with rows in it, and two
144
207
  // shapes for that would only say the widgets were written apart
145
- borderRadius: rowRadius(theme, MENU_BORDER, MENU_PAD),
208
+ borderRadius: metrics.radius,
146
209
  // nothing at rest — the sheet under it is already that colour, and a
147
210
  // rounded fill of the same colour is a coverage mask drawn to change
148
211
  // nothing, with four corners it deliberately leaves out
149
212
  backgroundColor: active ? theme.hoverBackground : 'transparent',
150
213
  },
151
214
  },
215
+ // The native menu's check, in the mark column to the left of the title.
216
+ // Drawn over the row's own left padding — NSMenu's mark sits inside the
217
+ // highlight, between its edge and the title.
218
+ metrics.check &&
219
+ selected &&
220
+ h(Icon, {
221
+ name: 'check',
222
+ size: capBand(metrics.fontSize),
223
+ color: active ? theme.hoverText : theme.text,
224
+ style: { position: 'absolute', left: metrics.markLeft },
225
+ }),
152
226
  h(
153
227
  'text',
154
228
  {
@@ -156,7 +230,8 @@ function Option({
156
230
  capTrim,
157
231
  {
158
232
  color: active ? theme.hoverText : theme.text,
159
- fontWeight: selected ? 'bold' : 'normal',
233
+ fontSize: metrics.fontSize,
234
+ fontWeight: selected ? metrics.selectedWeight : metrics.weight,
160
235
  },
161
236
  ],
162
237
  },
@@ -212,27 +287,61 @@ export function Select({
212
287
 
213
288
  const normalized = options.map(normalizeOption);
214
289
  const current = normalized.find((o) => o.value === value);
215
- // Rows are sized from the palette's text size, because that is the size
216
- // the labels inside them come out at.
217
- const rowHeight = itemHeight(theme.fontSize);
218
- const contentHeight = normalized.length * rowHeight + MENU_PAD * 2;
290
+ // Rows are sized from the text size the labels inside them come out at:
291
+ // the palette's, or NSMenu's under a native popup bezel.
292
+ const metrics = menuMetrics(theme, nativeControls);
293
+ const rowHeight = metrics.row;
294
+ const contentHeight = normalized.length * rowHeight + metrics.pad * 2;
219
295
  const menuHeight = Math.min(contentHeight, MAX_MENU_HEIGHT);
220
296
  const scrolls = contentHeight > MAX_MENU_HEIGHT;
221
297
 
222
298
  // shared anchoring: also flips the menu above the trigger when there is
223
299
  // no room below, and slides it left when the menu is wider than the
224
300
  // trigger and would otherwise open off the right edge
225
- const menuAnchorOptions = () => ({
226
- placement: 'bottom',
227
- height: menuHeight + 2,
228
- width: menuWidth(
301
+ // A native popup button does not drop its menu below the control: it
302
+ // opens the menu *over* it, with the chosen row on top of the control and
303
+ // the two titles coincident — the menu's mark column hanging out to the
304
+ // left, the rows above and below it stacked up and down. The menu's
305
+ // title inset less the trigger's is how far left it starts. No flip: a
306
+ // menu over its control has no other side, and is clamped into the
307
+ // screen as AppKit's is.
308
+ const menuAnchorOptions = () => {
309
+ const height = menuHeight + 2;
310
+ const width = menuWidth(
229
311
  triggerRef.current,
230
312
  normalized,
231
313
  value,
232
314
  scrolls,
233
- theme.fontSize,
234
- ),
235
- });
315
+ metrics,
316
+ );
317
+ if (!nativeControls) return { placement: 'bottom', height, width };
318
+ const node = triggerRef.current;
319
+ const scale = node?.scale ?? 1;
320
+ const triggerHeight = node ? node.abs.height / scale : 0;
321
+ const shadow = bezelShadow(app, 'popup');
322
+ // Aligned on the *capitals*, which is what the eye lines up: the
323
+ // trigger's sit on the title baseline (`TITLE_BASELINE` above the bezel
324
+ // body's bottom), a row's on `capsBottom` — both whole pixels, both
325
+ // placed rather than centred, so the two agree at every scale.
326
+ const triggerCapsBottom =
327
+ triggerHeight - shadow.bottom - TITLE_BASELINE.regular;
328
+ const rowCapsBottom = metrics.capsBottom;
329
+ const chosen = Math.max(
330
+ 0,
331
+ normalized.findIndex((o) => o.value === value),
332
+ );
333
+ const chosenRowTop = MENU_BORDER + metrics.pad + chosen * metrics.row;
334
+ const top = triggerCapsBottom - chosenRowTop - rowCapsBottom;
335
+ return {
336
+ placement: 'bottom',
337
+ offset: 0,
338
+ flip: false,
339
+ at: { x: 0, y: top, width: node ? node.abs.width / scale : 0, height: 0 },
340
+ alignOffset: -metrics.hang.left,
341
+ height,
342
+ width,
343
+ };
344
+ };
236
345
 
237
346
  const close = () => setOpen(false);
238
347
  const openMenu = () => {
@@ -357,8 +466,20 @@ export function Select({
357
466
  alignItems: 'center',
358
467
  gap: 8,
359
468
  height: bezelNatural(app, 'popup').height,
360
- paddingLeft: 10,
469
+ paddingLeft: TRIGGER_PAD_LEFT,
361
470
  paddingRight: 26,
471
+ // The title sits where NSPopUpButtonCell puts it — on the
472
+ // same baseline as a push button's, above the body's bottom
473
+ // edge, the shadow padded off (see Button).
474
+ paddingTop: bezelShadow(app, 'popup').top,
475
+ paddingBottom:
476
+ bezelShadow(app, 'popup').bottom + TITLE_BASELINE.regular,
477
+ // the keyboard ring hugs the bezel's corners (see Button)
478
+ borderRadius: 6,
479
+ ':focus-visible': {
480
+ outlineWidth: NATIVE_RING.width,
481
+ outlineOffset: NATIVE_RING.offset,
482
+ },
362
483
  }
363
484
  : {
364
485
  cursor: 'pointer',
@@ -409,7 +530,13 @@ export function Select({
409
530
  }),
410
531
  h(
411
532
  'text',
412
- { style: [capTrim, { color: current ? theme.text : theme.textMuted }] },
533
+ {
534
+ style: [
535
+ capTrim,
536
+ nativeControls && nativeTitleStyle('regular'),
537
+ { color: current ? theme.text : theme.textMuted },
538
+ ],
539
+ },
413
540
  current ? current.label : placeholder,
414
541
  ),
415
542
  h('box', { style: { flexGrow: 1 } }),
@@ -484,7 +611,11 @@ export function Select({
484
611
  // for a Select; this pane is scrolled by wheel and by the
485
612
  // arrow keys the trigger already handles.
486
613
  focusable: false,
487
- style: { flexGrow: 1, padding: MENU_PAD, overflow: 'scroll' },
614
+ style: {
615
+ flexGrow: 1,
616
+ padding: metrics.pad,
617
+ overflow: 'scroll',
618
+ },
488
619
  },
489
620
  normalized.map((option, index) =>
490
621
  h(Option, {
@@ -497,6 +628,7 @@ export function Select({
497
628
  nodeRef: index === activeIndex ? activeRef : undefined,
498
629
  onHover: () => setActiveIndex(index),
499
630
  onPick: pick,
631
+ metrics,
500
632
  }),
501
633
  ),
502
634
  ),
@@ -9,6 +9,8 @@ import { changeEvent } from './change.js';
9
9
  import {
10
10
  ABS_FILL,
11
11
  Bezel,
12
+ NO_ROW_RING,
13
+ nativeRingStyle,
12
14
  bezelNatural,
13
15
  pressWash,
14
16
  useNativeControls,
@@ -116,6 +118,7 @@ export function Switch({
116
118
  height: nat.height,
117
119
  hitSlop: Math.max(0, (24 - nat.height) / 2),
118
120
  },
121
+ NO_ROW_RING,
119
122
  style,
120
123
  ],
121
124
  },
@@ -123,7 +126,11 @@ export function Switch({
123
126
  kind: 'switch',
124
127
  state: checked ? 1 : 0,
125
128
  enabled: !disabled,
126
- style: ABS_FILL,
129
+ style: [
130
+ ABS_FILL,
131
+ // the pill: the ring rounds with it
132
+ nativeRingStyle(theme, control.focusVisible, nat.height / 2),
133
+ ],
127
134
  }),
128
135
  h('box', {
129
136
  style: [
@@ -58,6 +58,105 @@ export function bezelNatural(app, kind, controlSize = 'regular') {
58
58
  return app?.nativeBezels?.natural(kind, controlSize) ?? null;
59
59
  }
60
60
 
61
+ /**
62
+ * The translucent rows above and below the bezel's solid body — a push
63
+ * button's shadow — as padding, so a label centred in the box is centred in
64
+ * the control rather than in its footprint. Zero where there is no store.
65
+ */
66
+ export function bezelShadow(app, kind, controlSize = 'regular') {
67
+ return (
68
+ app?.nativeBezels?.shadow?.(kind, controlSize) ?? { top: 0, bottom: 0 }
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Where NSButtonCell — and NSPopUpButtonCell — put a title: its baseline
74
+ * this far above the bezel body's bottom edge, at each control size. Not
75
+ * centred: the capitals ride a little above the middle and the descenders
76
+ * hang below it. Measured off the cells' own rendering of "Done" and "Blue"
77
+ * (macOS 15): the capitals sit 4.5pt below the top of a 20pt regular body
78
+ * and 6pt above its bottom, 4pt and 4pt in a 16pt small one.
79
+ */
80
+ export const TITLE_BASELINE = Object.freeze({ regular: 6, small: 4 });
81
+
82
+ /**
83
+ * The system control font at each size — `NSFont.systemFontSize` and
84
+ * `smallSystemFontSize` — which every bezel is designed around and the
85
+ * baseline above was measured with. A native label is set in it rather than
86
+ * in the palette's size: a 14px title in a small bezel has no room to sit
87
+ * where the cell's does.
88
+ */
89
+ export const CONTROL_FONT_SIZE = Object.freeze({ regular: 13, small: 11 });
90
+
91
+ /**
92
+ * The style of a title placed the way a cell places it: on the bottom of a
93
+ * content box padded by `TITLE_BASELINE` (a cap-trimmed label's bottom edge
94
+ * is its baseline, and the trim rounds the cap height to a whole pixel, so
95
+ * the baseline lands on one), at the control font size. Only the label goes
96
+ * on the baseline: an icon beside it stays centred, as AppKit centres an
97
+ * image. Not sized to the cap band: a box that tall clips the ascenders —
98
+ * the l in "Blue" lost its top row.
99
+ */
100
+ export function nativeTitleStyle(controlSize = 'regular') {
101
+ return { alignSelf: 'flex-end', fontSize: CONTROL_FONT_SIZE[controlSize] };
102
+ }
103
+
104
+ /**
105
+ * NSMenu's geometry, for the menu a native popup bezel opens — read off
106
+ * `NSMenu.size` on macOS 15 rather than off a screenshot, so it is the
107
+ * menu's own arithmetic: a row is 22pt, the sheet pads 5pt top and bottom,
108
+ * a separator is 11pt, and the font is the 13pt menu font at regular
109
+ * weight. A title starts 13pt in from the edge — 5 to the highlight, 8
110
+ * inside it, which is the mark column a menu always reserves — and 28.5pt
111
+ * of chrome stand between a title's width and the menu's. The highlight's
112
+ * inset is the pad; its radius is the one number here read off the screen.
113
+ */
114
+ export const NATIVE_MENU = Object.freeze({
115
+ fontSize: 13,
116
+ weight: 'normal',
117
+ row: 22,
118
+ pad: 5,
119
+ padLeft: 8,
120
+ padRight: 10,
121
+ radius: 5,
122
+ separator: 11,
123
+ // A popup button's menu reserves a column for the check that marks the
124
+ // chosen item, and its titles start that much further in: 38pt of chrome
125
+ // against the plain menu's 28.5 (`NSPopUpButton.menu.size`). The mark
126
+ // itself sits 11pt in from the edge, which is 5 inside the row.
127
+ markColumn: 9,
128
+ markLeft: 5,
129
+ });
130
+
131
+ /**
132
+ * AppKit's focus ring: one band, outside the control, following the
133
+ * control's own shape — a circle round a radio, a rounded square round a
134
+ * checkbox, the bezel's corners round a button. Never round the label, and
135
+ * never two: a native row that lit its keyboard ring round the whole row
136
+ * *and* the part's ring round the bezel showed a rectangle round the label
137
+ * beside a rectangle round the control. Width read off the screen (3pt,
138
+ * hugging the edge); the colour is the palette's ring, which on macOS is
139
+ * `keyboardFocusIndicatorColor` over the ground.
140
+ *
141
+ * Only for focus that shows: a click on a checkbox lights no ring in
142
+ * AppKit, Tab does — which is `useControl().focusVisible`.
143
+ */
144
+ export const NATIVE_RING = Object.freeze({ width: 3, offset: 0 });
145
+
146
+ /** The ring, as style for the bezel node, shaped to `radius`. */
147
+ export function nativeRingStyle(theme, on, radius) {
148
+ if (!on) return null;
149
+ return {
150
+ outlineWidth: NATIVE_RING.width,
151
+ outlineColor: theme.focusRing,
152
+ outlineOffset: NATIVE_RING.offset,
153
+ borderRadius: radius,
154
+ };
155
+ }
156
+
157
+ /** The row round a native part opts out of the renderer's own ring. */
158
+ export const NO_ROW_RING = Object.freeze({ outlineWidth: 0 });
159
+
61
160
  /** Absolute fill inside the control's box — where every bezel layer goes. */
62
161
  export const ABS_FILL = Object.freeze({
63
162
  position: 'absolute',
@@ -10,7 +10,12 @@
10
10
  import React, { useContext, useMemo, useState } from 'react';
11
11
  import { useAppearanceWhen } from '../appearancehooks.js';
12
12
  import { EnvValue, registerFrameProvider } from '../frame/env.js';
13
- import { DarkTheme, DefaultTheme, resolveTheme } from '../palette.js';
13
+ import {
14
+ DarkTheme,
15
+ DefaultTheme,
16
+ paletteFor,
17
+ resolveTheme,
18
+ } from '../palette.js';
14
19
 
15
20
  const h = React.createElement;
16
21
 
@@ -67,14 +72,15 @@ const FILL = Object.freeze({ flexGrow: 1 });
67
72
  * <ThemeProvider value={light} dark={{ background: '#101418' }}>
68
73
  * ```
69
74
  *
70
- * The desktop's **accent** is deliberately not adopted on its own: an app in
71
- * dark mode did not ask for its buttons to change colour, and most portal
72
- * backends report no accent at all. Take it explicitly where you want it, and
73
- * keep a fallback:
75
+ * The desktop's **accent** is part of what a following provider follows: the
76
+ * base under `value` is the desktop's palette, accent and all, where the
77
+ * desktop reports one (`paletteFor`). A brand keeps its own colour by naming
78
+ * it — `value` wins over the base token for token — and a pinned
79
+ * `colorScheme` follows nothing, the accent included:
74
80
  *
75
81
  * ```jsx
76
- * const { accent } = useSystemAppearance();
77
- * <ThemeProvider value={{ ...brand, accent: accent ?? brand.accent }}>
82
+ * <ThemeProvider value={{ accent: '#e17055', accentHover: '#c0563a' }}> // brand accent, desktop scheme
83
+ * <ThemeProvider value={brand} colorScheme="light"> // neither
78
84
  * ```
79
85
  */
80
86
  export function ThemeProvider({
@@ -93,16 +99,17 @@ export function ThemeProvider({
93
99
  const system = useAppearanceWhen(follows);
94
100
  const wantsDark =
95
101
  colorScheme === 'dark' || (follows && system.colorScheme === 'dark');
102
+ // An outer provider is the base; with none, the base is the desktop's
103
+ // palette when this provider follows it and the scheme's own built-in one
104
+ // when pinned. So `value` names what this app changes and everything else
105
+ // keeps following the desktop. `paletteFor` answers with one object per
106
+ // desktop answer, so the memo below holds across renders.
107
+ const base =
108
+ outer ??
109
+ (follows ? paletteFor(system) : wantsDark ? DarkTheme : DefaultTheme);
96
110
  const theme = useMemo(
97
- () =>
98
- // An outer provider is the base; with none, the base is the scheme's
99
- // own built-in palette. So `value` names what this app changes and
100
- // everything else keeps following the desktop.
101
- resolveTheme(
102
- dark && wantsDark ? { ...value, ...dark } : value,
103
- outer ?? (wantsDark ? DarkTheme : DefaultTheme),
104
- ),
105
- [outer, value, dark, wantsDark],
111
+ () => resolveTheme(dark && wantsDark ? { ...value, ...dark } : value, base),
112
+ [base, value, dark, wantsDark],
106
113
  );
107
114
  // A provider that **names** a direction plants it as a style too, so the
108
115
  // node tree mirrors along with the widgets: `useTheme().direction` is what
@@ -181,14 +188,14 @@ function planted(children, theme, style) {
181
188
  *
182
189
  * Identity matters: widgets plant what this returns on their own root node,
183
190
  * and a fresh object every render would re-resolve every `$token` beneath it
184
- * and defeat the resolution cache. Both built-in palettes are module
185
- * constants, so the unprovided answer is stable too.
191
+ * and defeat the resolution cache. `paletteFor` answers with one object per
192
+ * desktop answer, so the unprovided answer is stable too.
186
193
  */
187
194
  export function useTheme() {
188
195
  const provided = useContext(ThemeContext);
189
196
  const system = useAppearanceWhen(provided == null);
190
197
  if (provided) return provided;
191
- return system.colorScheme === 'dark' ? DarkTheme : DefaultTheme;
198
+ return paletteFor(system);
192
199
  }
193
200
 
194
201
  /**
@@ -240,6 +247,11 @@ export function useDirection() {
240
247
  export function useControl(disabled, onActivate, { styled = false } = {}) {
241
248
  const [hover, setHover] = useState(false);
242
249
  const [focused, setFocused] = useState(false);
250
+ // …and whether that focus is the kind that shows: the `:focus-visible`
251
+ // the focus manager has already decided by the time `onFocus` fires
252
+ // (Tab lights it, a click does not, except into a text control). A
253
+ // native control draws its ring for this and not for `focused`.
254
+ const [focusVisible, setFocusVisible] = useState(false);
243
255
  const [pressed, setPressed] = useState(false);
244
256
  const activation = {
245
257
  focusable: true,
@@ -275,15 +287,20 @@ export function useControl(disabled, onActivate, { styled = false } = {}) {
275
287
  },
276
288
  onMouseDown: () => setPressed(true),
277
289
  onMouseUp: () => setPressed(false),
278
- onFocus: () => setFocused(true),
290
+ onFocus: (ev) => {
291
+ setFocused(true);
292
+ setFocusVisible(ev?.target?.states?.[':focus-visible'] === true);
293
+ },
279
294
  onBlur: () => {
280
295
  setFocused(false);
296
+ setFocusVisible(false);
281
297
  setPressed(false);
282
298
  },
283
299
  };
284
300
  return {
285
301
  hover: !styled && hover && !disabled,
286
302
  focused: !styled && focused && !disabled,
303
+ focusVisible: !styled && focusVisible && !disabled,
287
304
  pressed: !styled && pressed && !disabled,
288
305
  props,
289
306
  // `cursor` is style, so it travels in the style channel — put it first
@@ -17,7 +17,11 @@
17
17
  // XQuartz — the defaults below stand.
18
18
  //
19
19
  // So this reads the map `xsettings.js` already maintains, rather than
20
- // standing up a source of its own.
20
+ // standing up a source of its own. The one exception is macOS, which has no
21
+ // XSETTINGS and answers exactly one of these — "reduce motion", through
22
+ // `NSWorkspace` — so the Cocoa app exposes that behind a two-method seam
23
+ // (`accessibilityDisplayOptions`, `watchAccessibilityDisplay`) and this
24
+ // module reads it the way it reads the map: synchronously, cached, live.
21
25
  //
22
26
  // ## Two of these are read synchronously, which is what shapes the module
23
27
  //
@@ -85,6 +89,22 @@ function positive(map, key) {
85
89
  * conversion, and forgetting to is a caret that blinks at half speed and
86
90
  * looks broken rather than wrong.
87
91
  */
92
+ /**
93
+ * macOS's accessibility display options → the settings. Only `animations`
94
+ * comes from the system there: it is the one of these macOS publishes at all
95
+ * (System Settings › Accessibility › Display › Reduce motion, which the
96
+ * bridge reads from `NSWorkspace`), and the rest keep this renderer's
97
+ * defaults rather than being guessed from something that is not them. Pure,
98
+ * and exported for the test.
99
+ */
100
+ export function fromMacOS(options) {
101
+ return Object.freeze({
102
+ ...DEFAULTS,
103
+ animations: options?.reduceMotion !== true,
104
+ source: 'macos',
105
+ });
106
+ }
107
+
88
108
  export function fromXSettings(map) {
89
109
  if (!map) return DEFAULTS;
90
110
  const cycle = positive(map, 'Net/CursorBlinkTime');
@@ -126,7 +146,9 @@ export function desktopSettings(app) {
126
146
  const session = sessions.get(app);
127
147
  if (!session) return DEFAULTS;
128
148
  if (!session.snapshot) {
129
- session.snapshot = fromXSettings(xsettings(app));
149
+ session.snapshot = session.macos
150
+ ? fromMacOS(app.accessibilityDisplayOptions())
151
+ : fromXSettings(xsettings(app));
130
152
  }
131
153
  return session.snapshot;
132
154
  }
@@ -141,6 +163,16 @@ export function beginDesktopSettings(app) {
141
163
  if (sessions.has(app)) return;
142
164
  const session = { snapshot: null, listeners: new Set(), stop: null };
143
165
  sessions.set(app, session);
166
+ // The Cocoa app: the answer is synchronous from the start, and the change
167
+ // arrives as a backend event rather than a property on a selection owner.
168
+ if (typeof app?.accessibilityDisplayOptions === 'function') {
169
+ session.macos = true;
170
+ session.stop = app.watchAccessibilityDisplay?.(() => {
171
+ session.snapshot = null;
172
+ notify(session);
173
+ });
174
+ return;
175
+ }
144
176
  beginXSettings(app).then(
145
177
  () => {
146
178
  if (!sessions.has(app)) return;