react-x11 2.9.0 → 2.9.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.9.0",
3
+ "version": "2.9.2",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -32,6 +32,7 @@
32
32
  "examples:tooltips": "tsx examples/tooltips.jsx",
33
33
  "examples:chat": "tsx examples/chat.jsx",
34
34
  "examples:clipboard": "tsx examples/clipboard.jsx",
35
+ "examples:calendar": "tsx examples/calendar.jsx",
35
36
  "labs:urischeme": "tsx examples/labs/urischeme.jsx",
36
37
  "filedialog:probe": "node scripts/filedialog-probe.mjs",
37
38
  "a11y:probe": "node scripts/a11y-probe.mjs",
@@ -92,13 +93,14 @@
92
93
  "node": ">=20.19"
93
94
  },
94
95
  "dependencies": {
96
+ "ical.js": "^2.2.1",
95
97
  "linebreak": "^1.1.0",
96
98
  "ntk": "^8.7.0",
97
99
  "react-reconciler": "^0.33.0",
98
100
  "yoga-layout": "^3.2.1"
99
101
  },
100
102
  "optionalDependencies": {
101
- "@windowkit/appkit": "^0.7.0",
103
+ "@windowkit/appkit": "^0.8.0",
102
104
  "dbus-native": "^0.15.1",
103
105
  "x11-dri": "^0.7.0"
104
106
  },
package/src/cocoa/app.js CHANGED
@@ -25,6 +25,7 @@ import { CocoaGLArea, cocoaGLConfig, resolveCocoaGLRuntime } from './glarea.js';
25
25
  import { CocoaDockMenu } from './dock.js';
26
26
  import { CocoaGlobalMenuExport } from './globalmenu.js';
27
27
  import { CocoaStatusItem } from './statusitem.js';
28
+ import { CocoaCalendars } from './calendar.js';
28
29
  import { CocoaNotifications } from './notifications.js';
29
30
  import { CocoaPaneHost } from './panehost.js';
30
31
  import { CocoaPermissions } from './permissions.js';
@@ -177,6 +178,17 @@ export class CocoaApp {
177
178
  ? new CocoaNotifications(this)
178
179
  : null;
179
180
 
181
+ // EventKit (src/cocoa/calendar.js). Present exactly when the bridge has
182
+ // it (>= 0.8), and its presence is the top rung of
183
+ // src/desktopcalendar.js's ladder for this app — an older bridge leaves
184
+ // the ladder to find `osascript` instead, which answers the same
185
+ // questions out of the same framework.
186
+ this.calendars =
187
+ typeof native.calendars === 'function' &&
188
+ typeof native.eventsBetween === 'function'
189
+ ? new CocoaCalendars(this)
190
+ : null;
191
+
180
192
  // The GL policy, glbackend.js's shape. No GLX exists here, so the
181
193
  // default is 'auto' (the direct backend where the runtime loads);
182
194
  // useSupports('shaders') stays false until the first <glarea> resolves
@@ -767,6 +779,12 @@ export class CocoaApp {
767
779
  case 'notification-dismissed':
768
780
  this.notifications?.route(ev);
769
781
  return this._afterInput();
782
+ case 'calendar-store-changed':
783
+ // EventKit's own notification, which names nothing that changed. A
784
+ // watcher's answer is to query again, and that query's result is a
785
+ // render, so the frame goes out the way an input's does.
786
+ this.calendars?.route(ev);
787
+ return this._afterInput();
770
788
  case 'animation-end':
771
789
  // the presenter that added the animation registered for its id; an
772
790
  // id nobody knows is an animation already forgotten (cancelled, or
@@ -0,0 +1,186 @@
1
+ // The user's calendars on the cocoa backend — EventKit (`EKEventStore`)
2
+ // through @windowkit/appkit (>= 0.8), the top rung of
3
+ // src/desktopcalendar.js's ladder. `CocoaApp.calendars` is this object and
4
+ // its *presence* is the capability, the rule `filePanels`, `permissions` and
5
+ // `notifications` follow, so the ladder never names a backend.
6
+ //
7
+ // EventKit is the macOS counterpart of Evolution Data Server plus GNOME
8
+ // Online Accounts in one framework: every account the user added in System
9
+ // Settings > Internet Accounts — iCloud, Google, Exchange, CalDAV, a
10
+ // subscribed feed — is served by one store, the desktop did the OAuth, and
11
+ // the app never sees a credential.
12
+ //
13
+ // Three things about the framework shape the code here:
14
+ //
15
+ // - **It expands recurrences itself.** `predicateForEventsWithStartDate…`
16
+ // answers occurrences, so there is no `ical.js` on this rung and no
17
+ // recurrence arithmetic to get wrong. The EDS rung pays for that
18
+ // client-side; this one does not.
19
+ // - **The predicate spans at most four years** (the framework's own limit),
20
+ // so a longer range is asked for in chunks and the pieces are merged —
21
+ // `chunkSpan` in ../desktopcalendar.js, shared with the `osascript` rung
22
+ // because it is the same framework underneath.
23
+ // - **An all-day event ends at the last second of its last day.** Every
24
+ // other rung, and `byDay`, read `end` as exclusive, so it is normalised
25
+ // here — at the edge, once, rather than in every consumer.
26
+ //
27
+ // A change to anything in the store arrives as one `calendar-store-changed`
28
+ // backend event that names nothing, so `watch` reports `kind: 'changed'`
29
+ // with a null calendar and no count. Re-query; do not try to patch.
30
+
31
+ import {
32
+ calendarBackendName,
33
+ chunkSpan,
34
+ hexFromComponents,
35
+ sortEvents,
36
+ withExclusiveEnd,
37
+ } from '../desktopcalendar.js';
38
+
39
+ /** `EKCalendar` as the shape every rung answers with. */
40
+ export function calendarInfo(cal) {
41
+ return {
42
+ uid: String(cal.id),
43
+ name: cal.title ?? '',
44
+ // EventKit has no "unchecked in the sidebar" state to report — a
45
+ // calendar hidden in Calendar.app is still in the store — so every
46
+ // calendar here is enabled, which is what `enabled` means to a caller
47
+ // filtering the list.
48
+ enabled: true,
49
+ color: hexFromComponents(cal.color),
50
+ backend: calendarBackendName(cal.type),
51
+ readOnly: cal.allowsModifications === false || cal.immutable === true,
52
+ // The account the calendar came from, which is what the EDS rung's
53
+ // `account` is too: an `EKSource` is the Internet Accounts entry.
54
+ account: cal.source?.title ?? undefined,
55
+ };
56
+ }
57
+
58
+ /** One occurrence from the bridge as the shape every rung answers with. */
59
+ export function desktopEvent(ev, byId) {
60
+ const meta = byId.get(String(ev.calendar));
61
+ const start = new Date(ev.start);
62
+ return {
63
+ uid: String(ev.id ?? ev.itemId ?? `${ev.calendar}:${ev.start}`),
64
+ summary: ev.title ?? '',
65
+ location: ev.location ?? undefined,
66
+ description: ev.notes ?? undefined,
67
+ start,
68
+ end: withExclusiveEnd(new Date(ev.end), Boolean(ev.allDay)),
69
+ allDay: Boolean(ev.allDay),
70
+ recurring: Boolean(ev.recurring),
71
+ calendar: meta
72
+ ? { uid: meta.uid, name: meta.name, color: meta.color }
73
+ : { uid: String(ev.calendar), name: '' },
74
+ };
75
+ }
76
+
77
+ export class CocoaCalendars {
78
+ constructor(app) {
79
+ this.app = app;
80
+ this._native = app._native;
81
+ this.backend = 'cocoa';
82
+ this._watchers = new Set();
83
+ }
84
+
85
+ /**
86
+ * The TCC grant, read without prompting. `'write-only'` is macOS 14's
87
+ * partial grant, which is a refusal to a reader — but its own word, so a
88
+ * caller that only saves events can tell it from a denial.
89
+ */
90
+ async access() {
91
+ return this.app.permissions
92
+ ? this.app.permissions.status('calendars')
93
+ : 'unknown';
94
+ }
95
+
96
+ /** The system prompt, once per app; the status after the user answered. */
97
+ async requestAccess() {
98
+ return this.app.permissions
99
+ ? this.app.permissions.request('calendars')
100
+ : 'unknown';
101
+ }
102
+
103
+ listCalendars() {
104
+ return new Promise((resolve, reject) => {
105
+ this._native.calendars((err, list) =>
106
+ err ? reject(err) : resolve((list ?? []).map(calendarInfo)),
107
+ );
108
+ });
109
+ }
110
+
111
+ _events(range) {
112
+ return new Promise((resolve, reject) => {
113
+ this._native.eventsBetween(range, (err, events) =>
114
+ err ? reject(err) : resolve(events ?? []),
115
+ );
116
+ });
117
+ }
118
+
119
+ /**
120
+ * The occurrences in a range, already expanded by the framework.
121
+ *
122
+ * `errors` is always empty here: there is one store, and a failure in it
123
+ * is a failure of the whole read rather than of one account. The EDS rung
124
+ * is the one where a single unreachable CalDAV server must not blank the
125
+ * month.
126
+ */
127
+ async eventsBetween(from, to, options = {}) {
128
+ const metas = options.calendars ?? (await this.listCalendars());
129
+ // "These calendars, of which there are none" is not "every calendar",
130
+ // which is what an empty filter means to the predicate underneath. A
131
+ // caller whose filter matched nothing must get nothing.
132
+ if (options.calendars && metas.length === 0) {
133
+ return { events: [], errors: [] };
134
+ }
135
+ const byId = new Map(metas.map((meta) => [meta.uid, meta]));
136
+ const ids = options.calendars ? metas.map((meta) => meta.uid) : undefined;
137
+
138
+ const events = [];
139
+ const seen = new Set();
140
+ for (const [start, end] of chunkSpan(from, to)) {
141
+ const raw = await this._events({ start, end, calendars: ids });
142
+ for (const one of raw) {
143
+ // An occurrence that straddles a chunk boundary is reported by both
144
+ // predicates; the caller must not see it twice.
145
+ const key = `${one.id} ${one.start}`;
146
+ if (seen.has(key)) continue;
147
+ seen.add(key);
148
+ events.push(desktopEvent(one, byId));
149
+ }
150
+ }
151
+ return { events: sortEvents(events), errors: [] };
152
+ }
153
+
154
+ /**
155
+ * `EKEventStoreChangedNotification`, as the ladder's change signal.
156
+ *
157
+ * The notification names neither the calendar nor what happened — a
158
+ * detail EventKit does not have — so the change carries a null calendar
159
+ * and no count, and re-querying is the only correct answer. Every watcher
160
+ * hears every change, whatever range it asked for, because a change
161
+ * outside a range can still move what is inside it (a recurrence master
162
+ * edited months away).
163
+ */
164
+ async watch(from, to, onChange) {
165
+ const entry = { onChange };
166
+ this._watchers.add(entry);
167
+ return async () => {
168
+ this._watchers.delete(entry);
169
+ };
170
+ }
171
+
172
+ /** `calendar-store-changed`, routed from the app's backend callback. */
173
+ route() {
174
+ for (const entry of [...this._watchers]) {
175
+ try {
176
+ entry.onChange({ calendar: null, kind: 'changed', count: null });
177
+ } catch {
178
+ // a watcher that throws is not the other watchers' problem
179
+ }
180
+ }
181
+ }
182
+
183
+ /** Nothing to release: the store belongs to the process, and a handle's
184
+ * own watchers are dropped by the stop function `watch` returned. */
185
+ async close() {}
186
+ }
@@ -11,10 +11,27 @@
11
11
  // is CommonJS + a .node binary, so `createRequire` is the honest loader.
12
12
  import { createRequire } from 'node:module';
13
13
 
14
- const require = createRequire(import.meta.url);
15
-
16
14
  const PACKAGE = '@windowkit/appkit';
17
15
 
16
+ // The loader is made on first use, and from the executable when there is
17
+ // no module URL to make it from. A bundle built for Node's single-executable
18
+ // format (docs/packaging.md, tier 3) is CommonJS, and esbuild leaves
19
+ // `import.meta` an empty object in that output — so a module-scope
20
+ // `createRequire(import.meta.url)` threw ERR_INVALID_ARG_VALUE the moment
21
+ // the backend loaded, in every cocoa app shipped as a SEA, before this
22
+ // could say what it was trying to load. `createRequire(process.execPath)`
23
+ // is the loader Node's SEA docs prescribe (the embedded main's `__filename`
24
+ // *is* the executable), and it answers both specs below: an absolute
25
+ // REACT_X11_CALAYERS_PATH resolves from anywhere, and the bare package name
26
+ // resolves through a `node_modules` beside the binary. Made lazily, a
27
+ // loader that cannot be made at all is reported by the error at the bottom
28
+ // rather than by a throw at import.
29
+ let require = null;
30
+ function load(spec) {
31
+ require ??= createRequire(import.meta.url ?? process.execPath);
32
+ return require(spec);
33
+ }
34
+
18
35
  let cached = null;
19
36
 
20
37
  export function loadNative() {
@@ -30,7 +47,7 @@ export function loadNative() {
30
47
  const path = process.env.REACT_X11_CALAYERS_PATH;
31
48
  for (const spec of [path, PACKAGE].filter(Boolean)) {
32
49
  try {
33
- const mod = require(spec);
50
+ const mod = load(spec);
34
51
  // the package's index.js exports the raw addon as `native`; a direct
35
52
  // path to a built checkout may be the addon itself
36
53
  cached = mod.native ?? mod;
@@ -13,23 +13,32 @@
13
13
  // bundled app must carry the usage-description keys
14
14
  // (`NSCameraUsageDescription` and friends) or a request never prompts.
15
15
 
16
- /** Apple's four words as the ladder's. */
16
+ /** Apple's words as the ladder's. `writeOnly` is macOS 14's partial grant
17
+ * for EventKit — a grant to a writer, a refusal to a reader — and stays its
18
+ * own word for exactly that reason. */
17
19
  const STATUS = Object.freeze({
18
20
  authorized: 'granted',
19
21
  denied: 'denied',
20
22
  restricted: 'restricted',
21
23
  notDetermined: 'prompt',
24
+ writeOnly: 'write-only',
22
25
  });
23
26
 
24
27
  export function statusFromBridge(status) {
25
28
  return STATUS[status] ?? 'unknown';
26
29
  }
27
30
 
28
- /** The bridge's options for a kind: `automation` carries its target. */
31
+ /** The bridge's options for a kind: `automation` carries its target, and
32
+ * `calendars` the level being asked for (`reminders` has no write-only
33
+ * grant, and the bridge refuses one with a TypeError). */
29
34
  function bridgeOptions(kind, options = {}) {
30
- return kind === 'automation' && options.target != null
31
- ? { target: String(options.target) }
32
- : undefined;
35
+ if (kind === 'automation' && options.target != null) {
36
+ return { target: String(options.target) };
37
+ }
38
+ if (kind === 'calendars' && options.access != null) {
39
+ return { access: String(options.access) };
40
+ }
41
+ return undefined;
33
42
  }
34
43
 
35
44
  export class CocoaPermissions {
@@ -7,10 +7,12 @@ import { useAppOrNull } from '../appcontext.js';
7
7
  import {
8
8
  ABS_FILL,
9
9
  Bezel,
10
+ NATIVE_BAND,
10
11
  NATIVE_RING,
11
12
  TITLE_BASELINE,
12
13
  bezelNatural,
13
14
  bezelShadow,
15
+ nativeFootprintStyle,
14
16
  nativeTitleStyle,
15
17
  pressWash,
16
18
  useNativeControls,
@@ -94,64 +96,73 @@ export function Button({
94
96
  'box',
95
97
  {
96
98
  theme,
97
- role: 'button',
98
- ...props,
99
- ...boxProps,
100
- style: [
101
- controlStyle,
102
- {
103
- flexDirection: 'row',
104
- alignItems: 'center',
105
- justifyContent: 'center',
106
- gap: small ? 6 : 8,
107
- // AppKit's metrics, not the palette's: a native bezel is
108
- // designed at its own height, and stretching it is what this
109
- // mode exists to avoid. Width still follows the label.
110
- height: nat.height,
111
- paddingLeft: small ? 10 : 14,
112
- paddingRight: small ? 10 : 14,
113
- // The natural box is the bezel's footprint, shadow included;
114
- // the body is what the title is placed against — and placed,
115
- // not centred (`TITLE_BASELINE`). A label centred by its
116
- // capitals sat 1pt low beside a native button.
117
- paddingTop: shadow.top,
118
- paddingBottom: shadow.bottom + TITLE_BASELINE[controlSize],
119
- // The keyboard ring is the renderer's, on this box — shaped by
120
- // the bezel's corners and hugging it, as AppKit's is
121
- // (`NATIVE_RING`), rather than the palette's offset rectangle.
122
- borderRadius: small ? 5 : 6,
123
- ':focus-visible': {
124
- outlineWidth: NATIVE_RING.width,
125
- outlineOffset: NATIVE_RING.offset,
126
- },
127
- color: disabled
128
- ? theme.textMuted
129
- : primary
130
- ? theme.accentText
131
- : theme.text,
132
- },
133
- style,
134
- ],
99
+ // The footprint the caller's style sizes; the button below keeps
100
+ // AppKit's height inside it, centred (`nativeFootprintStyle`).
101
+ style: [nativeFootprintStyle(nat), style],
135
102
  },
136
- h(Bezel, {
137
- kind: 'push',
138
- controlSize,
139
- enabled: !disabled,
140
- // the Return-key accent fill is AppKit's own "default button"
141
- isDefault: primary && !disabled,
142
- style: ABS_FILL,
143
- }),
144
- labelContent(children ?? label, nativeTitleStyle(controlSize)),
145
- // The press answer. Last child on purpose: `:active` marks the
146
- // pressed node and its ancestors, and the topmost child is what the
147
- // press lands on. No hover tint — AppKit buttons have none.
148
- h('box', {
149
- style: [
150
- ABS_FILL,
151
- { borderRadius: small ? 5 : 6 },
152
- !disabled && { ':active': { backgroundColor: pressWash(theme) } },
153
- ],
154
- }),
103
+ h(
104
+ 'box',
105
+ {
106
+ theme,
107
+ role: 'button',
108
+ ...props,
109
+ ...boxProps,
110
+ style: [
111
+ controlStyle,
112
+ NATIVE_BAND,
113
+ {
114
+ flexDirection: 'row',
115
+ alignItems: 'center',
116
+ justifyContent: 'center',
117
+ gap: small ? 6 : 8,
118
+ // AppKit's metrics, not the palette's: a native bezel is
119
+ // designed at its own height, and stretching it is what this
120
+ // mode exists to avoid. Width still follows the label.
121
+ height: nat.height,
122
+ paddingLeft: small ? 10 : 14,
123
+ paddingRight: small ? 10 : 14,
124
+ // The natural box is the bezel's footprint, shadow included;
125
+ // the body is what the title is placed against — and placed,
126
+ // not centred (`TITLE_BASELINE`). A label centred by its
127
+ // capitals sat 1pt low beside a native button.
128
+ paddingTop: shadow.top,
129
+ paddingBottom: shadow.bottom + TITLE_BASELINE[controlSize],
130
+ // The keyboard ring is the renderer's, on this box — shaped by
131
+ // the bezel's corners and hugging it, as AppKit's is
132
+ // (`NATIVE_RING`), rather than the palette's offset rectangle.
133
+ borderRadius: small ? 5 : 6,
134
+ ':focus-visible': {
135
+ outlineWidth: NATIVE_RING.width,
136
+ outlineOffset: NATIVE_RING.offset,
137
+ },
138
+ color: disabled
139
+ ? theme.textMuted
140
+ : primary
141
+ ? theme.accentText
142
+ : theme.text,
143
+ },
144
+ ],
145
+ },
146
+ h(Bezel, {
147
+ kind: 'push',
148
+ controlSize,
149
+ enabled: !disabled,
150
+ // the Return-key accent fill is AppKit's own "default button"
151
+ isDefault: primary && !disabled,
152
+ style: ABS_FILL,
153
+ }),
154
+ labelContent(children ?? label, nativeTitleStyle(controlSize)),
155
+ // The press answer. Last child on purpose: `:active` marks the
156
+ // pressed node and its ancestors, and the topmost child is what the
157
+ // press lands on. No hover tint — AppKit buttons have none.
158
+ h('box', {
159
+ style: [
160
+ ABS_FILL,
161
+ { borderRadius: small ? 5 : 6 },
162
+ !disabled && { ':active': { backgroundColor: pressWash(theme) } },
163
+ ],
164
+ }),
165
+ ),
155
166
  );
156
167
  }
157
168
  const background = !solid
@@ -9,11 +9,13 @@ import { Icon } from './Icon.js';
9
9
  import {
10
10
  ABS_FILL,
11
11
  Bezel,
12
+ NATIVE_BAND,
12
13
  NATIVE_MENU,
13
14
  NATIVE_RING,
14
15
  TITLE_BASELINE,
15
16
  bezelNatural,
16
17
  bezelShadow,
18
+ nativeFootprintStyle,
17
19
  nativeTitleStyle,
18
20
  pressWash,
19
21
  useNativeControls,
@@ -438,7 +440,11 @@ export function Select({
438
440
  if (open) scrollRef.current?.scrollIntoView(activeRef.current);
439
441
  }, [open, activeIndex]);
440
442
 
441
- return h(
443
+ // AppKit's own popup metrics, which the trigger below is laid out at —
444
+ // and only the trigger: the footprint it sits in is the caller's to size.
445
+ const nat = nativeControls ? bezelNatural(app, 'popup') : null;
446
+
447
+ const trigger = h(
442
448
  'box',
443
449
  {
444
450
  theme,
@@ -465,7 +471,8 @@ export function Select({
465
471
  flexDirection: 'row',
466
472
  alignItems: 'center',
467
473
  gap: 8,
468
- height: bezelNatural(app, 'popup').height,
474
+ ...NATIVE_BAND,
475
+ height: nat.height,
469
476
  paddingLeft: TRIGGER_PAD_LEFT,
470
477
  paddingRight: 26,
471
478
  // The title sits where NSPopUpButtonCell puts it — on the
@@ -515,7 +522,10 @@ export function Select({
515
522
  ':hover': { backgroundColor: theme.surfaceHover },
516
523
  ':active': { backgroundColor: theme.surfaceActive },
517
524
  },
518
- style,
525
+ // The caller's style, on the trigger where the trigger is the whole
526
+ // control. Under a native bezel it sizes the footprint below instead
527
+ // and the trigger keeps AppKit's height (`nativeFootprintStyle`).
528
+ !nativeControls && style,
519
529
  ],
520
530
  },
521
531
  // `pressed` while the menu is down: AppKit's popup answers being open
@@ -635,4 +645,11 @@ export function Select({
635
645
  ),
636
646
  ),
637
647
  );
648
+
649
+ // The bezel and its title are one unit at NSPopUpButton's height; the box
650
+ // around them is what a `height`, a `flexGrow` or a parent's align-stretch
651
+ // is free to make taller, with the control centred in it (issue #510).
652
+ return nativeControls
653
+ ? h('box', { theme, style: [nativeFootprintStyle(nat), style] }, trigger)
654
+ : trigger;
638
655
  }
@@ -101,6 +101,47 @@ export function nativeTitleStyle(controlSize = 'regular') {
101
101
  return { alignSelf: 'flex-end', fontSize: CONTROL_FONT_SIZE[controlSize] };
102
102
  }
103
103
 
104
+ /**
105
+ * The **footprint** a native control sits in: the box the caller's style
106
+ * sizes, with the control — bezel, title and press wash, one unit at
107
+ * AppKit's own metrics — centred inside it.
108
+ *
109
+ * Two boxes rather than one, because the title is *placed* and not centred:
110
+ * it rides `TITLE_BASELINE` above the bezel body's bottom edge, and that is
111
+ * only where the cell puts it while the box is exactly `bezelNatural` tall.
112
+ * The caller's style is applied last and flex stretches a box regardless, so
113
+ * it was not always: `style={{ height: 44 }}`, a `height: '100%'` in a taller
114
+ * parent and a bare `flexGrow: 1` each made the box taller, and the two
115
+ * halves — the bezel absolutely filling it, the title glued to its bottom —
116
+ * came apart, the label landing below the control it names (issue #510). The
117
+ * drawn path never had this, because it centres its label: a stretched drawn
118
+ * control is merely roomy, where a stretched native one was broken.
119
+ *
120
+ * So the height a caller can move belongs to a box the control sits in, and
121
+ * the control keeps AppKit's. The default here is exactly today's box — an
122
+ * untouched control lays out as it always did, and the explicit height still
123
+ * resists a row's align-stretch — and the caller's style, applied after it,
124
+ * moves the footprint alone. The control stretches across it (align-stretch,
125
+ * the default), so a footprint given a width is a bezel that wide.
126
+ *
127
+ * The control, not the footprint, stays the *control*: the role, the
128
+ * handlers, the focus ring and the ref go on it, so a press in the slack
129
+ * above a 22pt popup in a 46pt hole does nothing at all — as it does in
130
+ * AppKit — rather than firing a button with no visible answer to the press.
131
+ */
132
+ export function nativeFootprintStyle(nat) {
133
+ return { height: nat.height, justifyContent: 'center' };
134
+ }
135
+
136
+ /**
137
+ * The rest of what a native control's own box says about its size: nothing
138
+ * shrinks it. A squashed bezel is as wrong as a stretched one, and a
139
+ * footprint shorter than the control is a caller asking for something the
140
+ * cell's metrics cannot answer — so it overflows, visibly, rather than
141
+ * quietly drawing a title where the bezel is not.
142
+ */
143
+ export const NATIVE_BAND = Object.freeze({ flexShrink: 0 });
144
+
104
145
  /**
105
146
  * NSMenu's geometry, for the menu a native popup bezel opens — read off
106
147
  * `NSMenu.size` on macOS 15 rather than off a screenshot, so it is the