react-x11 2.1.4 → 2.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/Reconciler.js CHANGED
@@ -53,6 +53,7 @@ import { endIdle } from './idle.js';
53
53
  import { endKeyboardState } from './keyboardstate.js';
54
54
  import { endXSettings } from './xsettings.js';
55
55
  import { watchAppearance } from './appearance.js';
56
+ import { setDesktopIntegration } from './desktopintegration.js';
56
57
  import { ForeignNode } from './foreignnodes.js';
57
58
  import { GlAreaNode } from './glnodes.js';
58
59
  import { createRegisteredNode, registeredElements } from './registry.js';
@@ -605,6 +606,12 @@ function watchConnection(app, onDisconnect, deliberate) {
605
606
  *
606
607
  * `display`, `fontSource`, `glxVisual` and `onXError` go straight to ntk.
607
608
  * Anything else ntk understands, build the client yourself and pass `app`.
609
+ *
610
+ * `desktop: false` turns off the three things this turns on for you that talk
611
+ * to the session bus — the appearance ladder, the accessibility bridge and
612
+ * the global menu — for an embedder that owns them, or a process that must
613
+ * not fork. `desktop: { appearance: false }` names one. See
614
+ * src/desktopintegration.js and docs/desktop.md.
608
615
  */
609
616
  export async function createRoot(options = {}) {
610
617
  // Before anything builds a node: every drawn node creates a yoga node in
@@ -627,6 +634,11 @@ export async function createRoot(options = {}) {
627
634
  }
628
635
  const { app: borrowed, onDisconnect, ...rest } = options;
629
636
  const owned = borrowed === undefined;
637
+ // Before anything starts, for two reasons: a bad `desktop` shape must throw
638
+ // with nothing in flight, like the check above it — and `startA11y()` below
639
+ // reads this policy, so it has to be settled before the first await, not
640
+ // after (src/desktopintegration.js).
641
+ setDesktopIntegration(rest.desktop);
630
642
  // The connection is started first, and the order is the point rather than a
631
643
  // detail. `loadLayout()` is only nominally asynchronous: instantiating the
632
644
  // engine blocks the event loop for 15-50 ms before it returns its promise
package/src/a11y.js CHANGED
@@ -40,6 +40,10 @@
40
40
  // from gi.repository import Atspi; \
41
41
  // print({k: int(getattr(Atspi.Role, k)) for k in dir(Atspi.Role) if not k.startswith('_')})"
42
42
 
43
+ // The one import, and it keeps this file's promise: a `Set` and two functions
44
+ // over it, with no D-Bus, no node builtins and nothing at module scope.
45
+ import { desktopIntegrationEnabled } from './desktopintegration.js';
46
+
43
47
  /** AT-SPI role numbers (AtspiRole). */
44
48
  export const ATSPI_ROLE = Object.freeze({
45
49
  INVALID: 0,
@@ -1245,6 +1249,10 @@ let startPromise = null;
1245
1249
  * tests are exactly this case.
1246
1250
  */
1247
1251
  function a11yEnabled() {
1252
+ // `createRoot({ desktop: false })` outranks every environment variable,
1253
+ // including the one that forces the climb: it is the embedder saying this
1254
+ // process does not talk to the desktop (src/desktopintegration.js, #417).
1255
+ if (!desktopIntegrationEnabled('a11y')) return false;
1248
1256
  const own = process.env.REACT_X11_A11Y;
1249
1257
  if (own === '0') return false;
1250
1258
  if (own) return true;
package/src/appearance.js CHANGED
@@ -57,6 +57,7 @@ import os from 'node:os';
57
57
  import path from 'node:path';
58
58
 
59
59
  import { sessionBus } from './bus.js';
60
+ import { desktopIntegrationEnabled } from './desktopintegration.js';
60
61
  import { PORTAL_NAME, PORTAL_PATH } from './portal.js';
61
62
  import { beginXSettings, watchXSettings, xsettings } from './xsettings.js';
62
63
 
@@ -238,6 +239,12 @@ function sanitize(saved) {
238
239
  function load() {
239
240
  if (cacheChecked) return;
240
241
  cacheChecked = true;
242
+ // `createRoot({ desktop: false })` means this process does not follow the
243
+ // desktop, and that has to include the remembered answer: seeding from the
244
+ // cache would leave an app that opted out drawing in whatever colours this
245
+ // machine happened to be in last time, which is the opposite of the
246
+ // determinism the switch is asked for (#417).
247
+ if (!desktopIntegrationEnabled('appearance')) return;
241
248
  const file = cacheFile();
242
249
  if (!file) return;
243
250
  try {
@@ -702,6 +709,10 @@ async function runLadder(app) {
702
709
  */
703
710
  export function systemAppearance(options = {}) {
704
711
  if (owner) return Promise.resolve(snapshot);
712
+ // Turned off, so there is nothing to climb and nothing to remember: the
713
+ // defaults, which is a real answer — `'no-preference'` means *use your own*
714
+ // (src/desktopintegration.js).
715
+ if (!desktopIntegrationEnabled('appearance')) return Promise.resolve(NOTHING);
705
716
  load();
706
717
  if (!probe) {
707
718
  // **Failure is not cached**, for the same reason `bus.js` does not cache
package/src/atspi.js CHANGED
@@ -32,7 +32,7 @@
32
32
  // resurrected: the slots are cleared and the app simply stops being
33
33
  // accessible until restarted, the same contract bus.js documents.
34
34
 
35
- import { addressFor, loadTransport } from './bus.js';
35
+ import { loadTransport, resolveAddress } from './bus.js';
36
36
  import {
37
37
  hooks,
38
38
  ATSPI_ROLE,
@@ -1818,8 +1818,11 @@ async function connected(bus) {
1818
1818
  */
1819
1819
  async function accessibilityBusAddress(dbus) {
1820
1820
  if (process.env.AT_SPI_BUS_ADDRESS) return process.env.AT_SPI_BUS_ADDRESS;
1821
- const busAddress = addressFor('session');
1822
- if (!busAddress && process.platform !== 'darwin') return null;
1821
+ // Resolved, not read: on macOS this is where the session bus address comes
1822
+ // from, and letting `dbus-native` find it for itself would be a blocking
1823
+ // `spawnSync` on the way to the first frame (#417).
1824
+ const busAddress = await resolveAddress('session');
1825
+ if (!busAddress) return null;
1823
1826
  let sbus = null;
1824
1827
  try {
1825
1828
  sbus = dbus.createClient({ busAddress });
package/src/bus.js CHANGED
@@ -135,9 +135,8 @@ export async function loadTransport() {
135
135
  * address is incoherent under sharing. Two callers, two addresses, one
136
136
  * socket — which would win? Tests use this same seam.
137
137
  *
138
- * Not public, but exported for atspi.js, whose one-shot discovery probe
139
- * dials its own short-lived connection rather than the shared one — see
140
- * the note in `accessibilityBusAddress`.
138
+ * Synchronous, and so it cannot answer on macOS — see `resolveAddress`,
139
+ * which is what every dial goes through.
141
140
  */
142
141
  export function addressFor(kind) {
143
142
  if (kind === 'system') {
@@ -149,21 +148,102 @@ export function addressFor(kind) {
149
148
  if (process.env.DBUS_SESSION_BUS_ADDRESS) {
150
149
  return process.env.DBUS_SESSION_BUS_ADDRESS;
151
150
  }
152
- // macOS advertises the session bus through launchd, which dbus-native
153
- // already falls back to on its own. Leave it undefined and let it.
151
+ // macOS advertises the session bus through launchd rather than through the
152
+ // environment, and asking launchd is a subprocess see `launchdAddress`.
154
153
  if (process.platform === 'darwin') return undefined;
155
154
  const runtimeDir = process.env.XDG_RUNTIME_DIR;
156
155
  return runtimeDir ? `unix:path=${runtimeDir}/bus` : undefined;
157
156
  }
158
157
 
158
+ /**
159
+ * The in-flight or settled lookup, shared by every dial in the process.
160
+ *
161
+ * **Failure is cached here**, which is the one place in this module where it
162
+ * is — and the exception is deliberate. A session bus can genuinely appear
163
+ * under `$XDG_RUNTIME_DIR` mid-run, so that answer is never remembered; what
164
+ * launchd exports is a *login session* fact, set when the bus is installed
165
+ * and started, which does not happen under a running app. Re-asking would be
166
+ * a fork per feature probe to learn the same "no".
167
+ */
168
+ let launchd = null;
169
+
170
+ /**
171
+ * Ask launchd where the session bus is, **without blocking the event loop**.
172
+ *
173
+ * This is the whole reason `resolveAddress` exists (#417). `dbus-native` will
174
+ * do this lookup itself, from `createStream`, with `spawnSync` — it has to,
175
+ * because its own entry point is synchronous — and a fork+exec of `launchctl`
176
+ * on a cold page cache costs 120–150 ms of *blocked loop*, measured. Landing
177
+ * that inside `createRoot()` stalls the X handshake, the yoga instantiate and
178
+ * the first paint behind a question about the colour scheme.
179
+ *
180
+ * Every caller here is already asynchronous, so the same lookup done with
181
+ * `execFile` costs the same wall clock and none of the loop: the handshake
182
+ * and the layout engine run through it. Handing `dbus-native` the resolved
183
+ * `unix:path=…` is then what keeps it off its own synchronous path.
184
+ *
185
+ * The variable name is D-Bus's, and the fallback to our own environment is
186
+ * `launchdSocketPath`'s: a process launched from a shell that has it already
187
+ * knows the answer.
188
+ */
189
+ function launchdAddress() {
190
+ if (launchd) return launchd;
191
+ launchd = (async () => {
192
+ const VAR = 'DBUS_LAUNCHD_SESSION_BUS_SOCKET';
193
+ let fromLaunchd = '';
194
+ try {
195
+ const { execFile } = await import('node:child_process');
196
+ fromLaunchd = await new Promise((resolve) => {
197
+ execFile(
198
+ 'launchctl',
199
+ ['getenv', VAR],
200
+ { encoding: 'utf8' },
201
+ (err, stdout) => resolve(err ? '' : String(stdout).trim()),
202
+ );
203
+ });
204
+ } catch {
205
+ // no `launchctl` on $PATH, or no child processes to be had at all
206
+ }
207
+ const socket = fromLaunchd || process.env[VAR] || '';
208
+ return socket ? `unix:path=${socket}` : undefined;
209
+ })();
210
+ return launchd;
211
+ }
212
+
213
+ /**
214
+ * Where to dial, resolved — the synchronous sources first, and launchd only
215
+ * where they have nothing to say.
216
+ *
217
+ * Not public, but exported for atspi.js, whose one-shot discovery probe
218
+ * dials its own short-lived connection rather than the shared one — see the
219
+ * note in `accessibilityBusAddress`. It matters that it goes through here
220
+ * too: two dials that each let `dbus-native` ask launchd are two blocking
221
+ * forks, and the answer is the same both times.
222
+ *
223
+ * @param {BusKind} kind
224
+ * @returns {Promise<string | undefined>}
225
+ */
226
+ export async function resolveAddress(kind) {
227
+ const direct = addressFor(kind);
228
+ if (direct !== undefined) return direct;
229
+ if (kind !== 'session' || process.platform !== 'darwin') return undefined;
230
+ return await launchdAddress();
231
+ }
232
+
159
233
  function noAddressError(kind) {
234
+ if (kind !== 'session') {
235
+ return new Error(
236
+ 'react-x11: no system bus address. $DBUS_SYSTEM_BUS_ADDRESS is unset ' +
237
+ 'and there is no default.',
238
+ );
239
+ }
160
240
  return new Error(
161
- `react-x11: no ${kind} bus address. ` +
162
- (kind === 'session'
163
- ? '$DBUS_SESSION_BUS_ADDRESS is unset and $XDG_RUNTIME_DIR is not ' +
164
- 'set either, which is normal over ssh, under a bare startx and in ' +
165
- 'most containers.'
166
- : '$DBUS_SYSTEM_BUS_ADDRESS is unset and there is no default.'),
241
+ 'react-x11: no session bus address. $DBUS_SESSION_BUS_ADDRESS is unset ' +
242
+ (process.platform === 'darwin'
243
+ ? 'and launchd has no $DBUS_LAUNCHD_SESSION_BUS_SOCKET either, which ' +
244
+ 'is normal on a Mac with no D-Bus installed.'
245
+ : 'and $XDG_RUNTIME_DIR is not set either, which is normal over ssh, ' +
246
+ 'under a bare startx and in most containers.'),
167
247
  );
168
248
  }
169
249
 
@@ -216,8 +296,11 @@ async function connect(kind, generation) {
216
296
  return fail(noTransportError(cause));
217
297
  }
218
298
 
219
- const busAddress = addressFor(kind);
220
- if (!busAddress && process.platform !== 'darwin') {
299
+ // Resolved rather than read, so `dbus-native` never reaches its own
300
+ // `spawnSync` fallback on macOS (#417) — and so "this Mac has no D-Bus"
301
+ // fails here, before a socket is dialled, instead of inside a constructor.
302
+ const busAddress = await resolveAddress(kind);
303
+ if (!busAddress) {
221
304
  return fail(noAddressError(kind));
222
305
  }
223
306
 
@@ -538,6 +621,10 @@ export function busRefs(kind) {
538
621
  * exported object survives into the next test.
539
622
  */
540
623
  export function _resetBusState() {
624
+ // Including what launchd said: a suite that pins `process.platform` would
625
+ // otherwise inherit the answer — or the absence of one — from a case that
626
+ // ran under a different one.
627
+ launchd = null;
541
628
  for (const kind of KINDS) {
542
629
  state[kind] = newState(kind, state[kind].generation + 1);
543
630
  notify(kind);
@@ -0,0 +1,98 @@
1
+ // Whether this process talks to the desktop at all — the `desktop` option on
2
+ // `createRoot()`, and the one place that decides what it covers.
3
+ //
4
+ // ## What is in the group and why
5
+ //
6
+ // Three things react-x11 turns on for you without being asked, and all three
7
+ // reach the session bus:
8
+ //
9
+ // `appearance` the light/dark, accent, contrast and reduced-motion ladder
10
+ // (src/appearance.js) — the settings portal, then macOS, then
11
+ // XSETTINGS
12
+ // `a11y` the AT-SPI bridge, started from `createRoot()`
13
+ // (src/a11y.js, docs/accessibility.md)
14
+ // `globalMenu` a `MenuBar` handing its menu to the panel instead of drawing
15
+ // it (src/globalmenu.js, docs/globalmenu.md)
16
+ //
17
+ // Each already had an off switch and each of those was an **environment
18
+ // variable** — `REACT_X11_A11Y=0`, `NO_AT_BRIDGE=1`,
19
+ // `REACT_X11_NO_GLOBAL_MENU=1`, or unsetting `DBUS_SESSION_BUS_ADDRESS` for
20
+ // the appearance ladder. That is a seam an app cannot reach for itself: the
21
+ // environment is inherited, so a process that sets one before `createRoot()`
22
+ // has also set it for every child it spawns, and the D-Bus one turns off the
23
+ // portals and the app's own services along with the follower. AGENTS.md asks
24
+ // for the off switch to be somewhere the embedder can actually stand.
25
+ //
26
+ // ## Why the policy is process-wide when the option is per-root
27
+ //
28
+ // Because so is the thing it describes. There is one desktop, one D-Bus
29
+ // identity, and one AT-SPI bridge per process — `startA11y()` says so in its
30
+ // name, and appearance.js says so in its header. A per-root flag over
31
+ // process-wide state would be a seam that reads as finer than it is.
32
+ //
33
+ // So **off wins, and off latches.** A root that says `desktop: false` is a
34
+ // root with a constraint — an embedder that owns the toplevel, a test, a
35
+ // daemon that must not fork — and a second root quietly turning the feature
36
+ // back on for it would be the bug. Turning something back *on* is a thing
37
+ // this module deliberately cannot do.
38
+ //
39
+ // The one honest limit: a feature already started stays started. `startA11y()`
40
+ // is memoised per process, so a second root's `desktop: false` stops the next
41
+ // climb and not the bridge that is already up. Pass it on the first root.
42
+
43
+ /** @typedef {'appearance'|'a11y'|'globalMenu'} DesktopFeature */
44
+
45
+ /** @type {DesktopFeature[]} */
46
+ const FEATURES = ['appearance', 'a11y', 'globalMenu'];
47
+
48
+ /** What has been turned off, for the life of the process. */
49
+ const off = new Set();
50
+
51
+ /**
52
+ * Apply a root's `desktop` option. Not public — `createRoot({ desktop })` is
53
+ * the public shape.
54
+ *
55
+ * `undefined` is the default and means every feature stays on; `false` turns
56
+ * all of them off; an object names them one at a time, and a key left out is
57
+ * left alone.
58
+ *
59
+ * @param {boolean | Partial<Record<DesktopFeature, boolean>> | undefined} desktop
60
+ */
61
+ export function setDesktopIntegration(desktop) {
62
+ if (desktop === undefined || desktop === true) return;
63
+ if (desktop === false) {
64
+ for (const feature of FEATURES) off.add(feature);
65
+ return;
66
+ }
67
+ if (typeof desktop !== 'object') {
68
+ throw new TypeError(
69
+ 'react-x11: createRoot({ desktop }) takes false or an object of ' +
70
+ `${FEATURES.map((f) => `${f}: false`).join(', ')} — got ` +
71
+ `${JSON.stringify(desktop)}.`,
72
+ );
73
+ }
74
+ for (const [feature, on] of Object.entries(desktop)) {
75
+ if (!FEATURES.includes(/** @type {DesktopFeature} */ (feature))) {
76
+ throw new TypeError(
77
+ `react-x11: createRoot({ desktop: { ${feature} } }) — no such ` +
78
+ `desktop integration. Expected ${FEATURES.join(', ')}.`,
79
+ );
80
+ }
81
+ if (on === false) off.add(feature);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Whether a feature may run. Every one of the three asks this first, ahead of
87
+ * its own environment variable, so `desktop: false` is the outermost answer.
88
+ *
89
+ * @param {DesktopFeature} feature
90
+ */
91
+ export function desktopIntegrationEnabled(feature) {
92
+ return !off.has(feature);
93
+ }
94
+
95
+ /** Test seam, not public: forget the latch. */
96
+ export function _resetDesktopIntegration() {
97
+ off.clear();
98
+ }
package/src/globalmenu.js CHANGED
@@ -57,6 +57,7 @@
57
57
  import { useEffect, useRef, useState } from 'react';
58
58
 
59
59
  import { loadTransport, sessionBus } from './bus.js';
60
+ import { desktopIntegrationEnabled } from './desktopintegration.js';
60
61
  import {
61
62
  DBUSMENU_IFACE,
62
63
  PROPERTY_TYPES,
@@ -126,6 +127,10 @@ const menuPathFor = (xid) => `/com/react_x11/menus/${xid}`;
126
127
  * it is the one that works without touching application code.
127
128
  */
128
129
  function globalMenuEnabled() {
130
+ // `createRoot({ desktop: false })` first: handing the menu to a panel is
131
+ // one of the three things core turns on for you, and that is the switch
132
+ // that turns the group off (src/desktopintegration.js, #417).
133
+ if (!desktopIntegrationEnabled('globalMenu')) return false;
129
134
  const flag = process.env.REACT_X11_NO_GLOBAL_MENU;
130
135
  return !flag || flag === '0';
131
136
  }
package/src/index.d.ts CHANGED
@@ -298,6 +298,43 @@ export interface RootOptions {
298
298
  * and coming back is the user's own Tab.
299
299
  */
300
300
  restoreFocusOnReveal?: boolean;
301
+ /**
302
+ * Whether this process talks to the desktop over D-Bus. On by default, and
303
+ * `false` turns off all three things react-x11 starts for you (#417,
304
+ * docs/desktop.md):
305
+ *
306
+ * | | |
307
+ * | --- | --- |
308
+ * | `appearance` | following the desktop's light/dark, accent, contrast and reduced motion (docs/appearance.md) |
309
+ * | `a11y` | the AT-SPI bridge that makes the app reachable by a screen reader (docs/accessibility.md) |
310
+ * | `globalMenu` | a `MenuBar` handing its menu to the panel instead of drawing it (docs/globalmenu.md) |
311
+ *
312
+ * ```js
313
+ * await createRoot({ desktop: false }); // none of it
314
+ * await createRoot({ desktop: { appearance: false } }); // just that one
315
+ * ```
316
+ *
317
+ * For an embedder that owns those integrations itself, a kiosk or daemon
318
+ * that must not fork a subprocess to find the bus, and a test that wants
319
+ * one answer on every machine. With all three off nothing dials the
320
+ * session bus at startup.
321
+ *
322
+ * **Off is process-wide and it latches.** There is one desktop and one
323
+ * D-Bus identity per process, so a second root cannot turn back on what
324
+ * another turned off — and a feature already started stays started, so
325
+ * pass this on the first root.
326
+ */
327
+ desktop?: boolean | DesktopIntegrationOptions;
328
+ }
329
+
330
+ /** Which desktop integrations run. See {@link CreateRootOptions.desktop}. */
331
+ export interface DesktopIntegrationOptions {
332
+ /** Follow the desktop's light/dark, accent, contrast and reduced motion. */
333
+ appearance?: boolean;
334
+ /** The AT-SPI bridge — whether a screen reader can see this app. */
335
+ a11y?: boolean;
336
+ /** Whether a `MenuBar` hands its menu to the panel. */
337
+ globalMenu?: boolean;
301
338
  }
302
339
 
303
340
  export interface ComposeOptions {
package/src/nodes.js CHANGED
@@ -1218,6 +1218,19 @@ function contentSpan(node, axis, intrinsic, out) {
1218
1218
  * one pass by `contentSpan`. `floored` collects what was written so the next
1219
1219
  * measurement can take it back off — a floor left in place would be read
1220
1220
  * back as content that cannot give, and could then only ratchet upwards.
1221
+ *
1222
+ * A floor is written **unrounded**, and the measurement it came from ran
1223
+ * with the pixel grid off (`measuringExactly`) for the reason given there:
1224
+ * rounding a floor grows the tree a pixel per nesting level. What that
1225
+ * leaves is a sharp edge in yoga worth knowing about before writing a
1226
+ * measure function. A line whose items are all held at their floors is one
1227
+ * yoga freezes item by item, subtracting each item's shrink factor from the
1228
+ * line's total as it goes; the total only cancels to zero if the sizes add
1229
+ * up exactly in binary. Three items of, say, 239.28 in a column that
1230
+ * overflows do not, and yoga divides the overflow by the rounding residue
1231
+ * instead of skipping the division — the items come back a billion pixels
1232
+ * tall (issue #411). Whole pixels cancel exactly, which is why the text
1233
+ * measures here answer in them (`TextNode._trim`).
1221
1234
  */
1222
1235
  function writeContentFloors(node, axis, mins, floored) {
1223
1236
  const axisIsMain = mainAxisOf(node) === axis;
@@ -4587,7 +4600,12 @@ export class TextNode extends Node {
4587
4600
 
4588
4601
  /** Height for a width: the paragraph shaped into whatever is on offer.
4589
4602
  * The offer is `Infinity` when nothing bounds it, which is also what
4590
- * `textWrap: 'nowrap'` asks for, so neither needs a mode. */
4603
+ * `textWrap: 'nowrap'` asks for, so neither needs a mode.
4604
+ *
4605
+ * Both answers are **whole pixels**, the trimmed one included — see
4606
+ * `_trim` for why the rounding is not cosmetic. The glyphs are placed
4607
+ * from the unrounded trim (`_placedLayout`), so what the rounding moves
4608
+ * is the bottom edge of the box, by less than half a pixel. */
4591
4609
  measureContent({ width }) {
4592
4610
  const layout = this._layoutFor(this._wrapWidth(width));
4593
4611
  if (!layout) return { width: 0, height: 0 };
@@ -4596,7 +4614,9 @@ export class TextNode extends Node {
4596
4614
  width: Math.ceil(layout.width),
4597
4615
  height: Math.max(
4598
4616
  0,
4599
- Math.ceil(layout.height) - (trim ? trim.top + trim.bottom : 0),
4617
+ trim
4618
+ ? Math.round(Math.ceil(layout.height) - (trim.top + trim.bottom))
4619
+ : Math.ceil(layout.height),
4600
4620
  ),
4601
4621
  };
4602
4622
  }
@@ -4852,6 +4872,21 @@ export class TextNode extends Node {
4852
4872
  * Measured in the coordinates the layout is **drawn** in, not the ones it
4853
4873
  * reports: `halfLeading` shifts it, and deriving the baseline from the
4854
4874
  * metrics again would silently disagree the day that shift changes.
4875
+ *
4876
+ * The amounts are fractions of a pixel and stay that way — the glyphs are
4877
+ * placed from them (`_placedLayout`). What must not stay fractional is the
4878
+ * **box** they leave behind, which is why `measureContent` rounds the
4879
+ * height it reports and this does not (issue #411).
4880
+ *
4881
+ * A trimmed label measures to the cap band, and a cap height is a fraction
4882
+ * of the em — so before the rounding, a column of trimmed titles handed
4883
+ * yoga three or four flex items whose main size had a fraction in it and
4884
+ * whose content floors (#249) were that same fraction. Yoga freezes a line
4885
+ * like that item by item and divides the overflow by a total shrink factor
4886
+ * that should have cancelled to zero; a fraction that is not exact in
4887
+ * binary leaves a rounding residue there instead, and dividing by it laid
4888
+ * the section titles of `examples/configurator` out 5.6 billion pixels
4889
+ * tall. See `writeContentFloors`, which is the other end of it.
4855
4890
  */
4856
4891
  _trim(layout) {
4857
4892
  if (this.style.textBoxTrim !== 'cap-alphabetic') return null;
@@ -6645,8 +6680,8 @@ export class TextInputNode extends Node {
6645
6680
  /** A preferred width, capped to whatever is on offer — `Infinity` when
6646
6681
  * nothing is, which is what makes the `Math.min` the whole rule. */
6647
6682
  measureContent({ width }) {
6648
- // Not rounded here: a trimmed `<text>` hands layout the raw cap band
6649
- // too, and rounding one of them and not the other is a pixel of
6683
+ // `_capBand` rounds, and a trimmed `<text>` rounds the same band the
6684
+ // same way rounding one of them and not the other is a pixel of
6650
6685
  // difference between a field and the button beside it.
6651
6686
  return { width: Math.min(150, width), height: this._capBand() };
6652
6687
  }
@@ -6896,12 +6931,16 @@ export class TextInputNode extends Node {
6896
6931
  };
6897
6932
  }
6898
6933
 
6934
+ /** Whole pixels either way: a field's height is a flex item's main size,
6935
+ * and a fractional one costs the tree its content floors (see
6936
+ * `TextNode._trim`, issue #411). The face with no `capHeight` to round is
6937
+ * the one that reaches the fallback. */
6899
6938
  _capBand() {
6900
6939
  const style = this.resolvedTextStyle();
6901
6940
  const cap = this.app?.fonts
6902
6941
  ?.match?.(style.family, { weight: style.weight, style: style.style })
6903
6942
  ?.metrics?.(style.size)?.capHeight;
6904
- return cap ? Math.round(cap) : this._lineHeight();
6943
+ return Math.round(cap || this._lineHeight());
6905
6944
  }
6906
6945
 
6907
6946
  /** Shaped layout of the current value, cached per (value, style,