react-x11 2.15.3 → 2.16.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 (57) hide show
  1. package/README.md +37 -0
  2. package/package.json +3 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/anchor.js +60 -18
  5. package/src/capabilities.js +29 -4
  6. package/src/cocoa/app.js +15 -9
  7. package/src/cocoa/context2d.js +23 -0
  8. package/src/cocoa/fonts.js +78 -0
  9. package/src/cocoa/presenter.js +17 -0
  10. package/src/cocoa/promotion.js +20 -0
  11. package/src/cocoa/relaunch.js +8 -3
  12. package/src/cocoa/symbols.js +64 -0
  13. package/src/cocoa/threaded.js +24 -4
  14. package/src/cocoa/window.js +362 -139
  15. package/src/components/ProgressBar.js +1 -1
  16. package/src/components/Slider.js +72 -39
  17. package/src/components/anchor.js +7 -2
  18. package/src/components/index.js +1 -0
  19. package/src/components/theme.js +32 -28
  20. package/src/desktopcapabilityhooks.js +29 -6
  21. package/src/filedialoghooks.js +3 -5
  22. package/src/frame/childmain.js +8 -20
  23. package/src/frame/env.js +2 -10
  24. package/src/icontheme.js +240 -0
  25. package/src/imagesource.js +83 -1
  26. package/src/index.js +3 -0
  27. package/src/node.d.ts +7 -0
  28. package/src/nodes/animation.js +17 -47
  29. package/src/nodes/cascade.js +17 -2
  30. package/src/nodes/image.js +63 -1
  31. package/src/nodes/kinds.js +12 -0
  32. package/src/nodes/layout.js +5 -1
  33. package/src/nodes/node.js +17 -3
  34. package/src/nodes/paint.js +117 -0
  35. package/src/nodes/scope.js +259 -0
  36. package/src/nodes/scrollable.js +53 -6
  37. package/src/nodes/text.js +2 -0
  38. package/src/nodes/textarea.js +1 -1
  39. package/src/nodes/textinput.js +1 -1
  40. package/src/nodes/window/anchoring.js +45 -18
  41. package/src/nodes/window/flush.js +6 -5
  42. package/src/nodes/window/popup.js +10 -0
  43. package/src/nodes/window/size.js +40 -2
  44. package/src/nodes/window/window.js +41 -14
  45. package/src/registry.js +2 -1
  46. package/src/settings.js +332 -0
  47. package/src/statusnotifier.js +164 -17
  48. package/src/styles.js +212 -8
  49. package/src/symbols.js +200 -0
  50. package/src/testing/mock-app.js +10 -0
  51. package/src/trayhooks.js +21 -5
  52. package/src/types/capabilities.d.ts +13 -1
  53. package/src/types/components.d.ts +33 -0
  54. package/src/types/elements.d.ts +57 -6
  55. package/src/types/style.d.ts +57 -0
  56. package/src/types/system.d.ts +104 -0
  57. package/src/types/tray.d.ts +14 -2
@@ -37,14 +37,55 @@
37
37
  //
38
38
  // The protocol carries far less about a click than AppKit does. There is no
39
39
  // click count, no modifier state, and no item rectangle — `Activate(x, y)`
40
- // gives the pointer position and nothing else. Those fields are reported as
40
+ // gives a position and nothing else. Those fields are reported as
41
41
  // `0`/`false` rather than guessed at, and `docs/desktop.md` says so, because a
42
42
  // tray menu that only opens on shift-click is an app built on a field this
43
43
  // rung cannot fill. The menu is the portable interaction; `onClick` is the
44
44
  // one that degrades.
45
+ //
46
+ // ## The position has no unit
47
+ //
48
+ // The spec says "screen coordinates" and stops, which on a scaled display is
49
+ // two different numbers — and the hosts split between them:
50
+ //
51
+ // - **X root coordinates, device pixels.** Plasma since 5.27, on purpose:
52
+ // its xembed-sni-proxy synthesises X clicks from them. GNOME's
53
+ // AppIndicator extension passes its stage coordinates through, which are
54
+ // device pixels on X11 and in Wayland's physical layout, the default up
55
+ // to GNOME 49.
56
+ // - **The host's own logical pixels.** xfce4-panel, Budgie and LXQt pass
57
+ // their toolkit's root position through, and Cinnamon sends the icon's
58
+ // corner over its UI scale. GNOME's stage is in logical pixels from 50,
59
+ // and before it on Fedora and Debian 13, which switch the layout on
60
+ // downstream. So did Plasma before 5.27.
61
+ // - **Not a screen position.** MATE sends the item's corner in its applet's
62
+ // own window, and snixembed's `Activate` is always `(0, 0)`.
63
+ //
64
+ // Dividing by the scale is right for the first group and halves every click
65
+ // in the second; passing the numbers through is the opposite. So a click is
66
+ // read both ways and the screen picks (`clickReadings`, `clickPoint`):
67
+ //
68
+ // 1. **A reading no monitor holds is not one.** A tray is on the screen,
69
+ // and a device-pixel host's item at the right or the bottom of it —
70
+ // where panels put the tray — is off the screen when read as logical
71
+ // pixels. The common device-pixel click costs nothing more.
72
+ // 2. **Then the pointer.** A host that sends a position sends the
73
+ // pointer's or its icon's, so one `QueryPointer` names the reading the
74
+ // click was at.
75
+ // 3. **Otherwise the numbers as sent.** The pointer is somewhere else
76
+ // because the click was a key, or the connection is XWayland's, which
77
+ // is told nothing of a pointer over a Wayland panel. As sent is what the
78
+ // second group needs, and the first group's usual trays were settled at
79
+ // step 1.
80
+ //
81
+ // What that leaves wrong is a device-pixel host under XWayland whose tray
82
+ // still lands on a monitor read as logical pixels: in the top-left quarter
83
+ // of the screen, or on a desk where it falls on another monitor.
45
84
 
46
85
  import { loadTransport, sessionBus } from './bus.js';
47
86
  import { DbusMenuExport } from './dbusmenuexport.js';
87
+ import { scaleOf } from './scale.js';
88
+ import { screensSnapshot } from './screens.js';
48
89
 
49
90
  export const WATCHER_NAME = 'org.kde.StatusNotifierWatcher';
50
91
  export const WATCHER_PATH = '/StatusNotifierWatcher';
@@ -91,6 +132,43 @@ export function toPixmapArray(image) {
91
132
  return [[width, height, out]];
92
133
  }
93
134
 
135
+ /**
136
+ * How far from the pointer a click's point may be and still be the click, in
137
+ * logical pixels. A host that sends the pointer is within a pixel or two of
138
+ * it, and Cinnamon, which sends its icon's corner, within the icon. A point
139
+ * further off is taken as sent — which for Cinnamon, a logical-pixel host,
140
+ * is right anyway.
141
+ */
142
+ const CLICK_REACH = 64;
143
+
144
+ /**
145
+ * A host's `(x, y)` read both ways — as X root coordinates, and as the host's
146
+ * own logical pixels — keeping the readings some monitor holds. Each is the
147
+ * point on the screen it names, in device pixels, and what it means in
148
+ * logical ones. See "The position has no unit" in the header.
149
+ *
150
+ * `screens` are device-pixel rects, as `screensSnapshot(app).screens` holds
151
+ * them; with none known both readings stand, and with neither on a monitor
152
+ * the host sent something that is not a position.
153
+ */
154
+ function clickReadings(x, y, scale, screens = []) {
155
+ const readings = [
156
+ { device: { x, y }, logical: { x: x / scale, y: y / scale } },
157
+ { device: { x: x * scale, y: y * scale }, logical: { x, y } },
158
+ ];
159
+ if (!screens.length) return readings;
160
+ // Edges count: a host that sends an icon's far corner can land on one.
161
+ return readings.filter(({ device: p }) =>
162
+ screens.some(
163
+ (m) =>
164
+ p.x >= m.x &&
165
+ p.y >= m.y &&
166
+ p.x <= m.x + m.width &&
167
+ p.y <= m.y + m.height,
168
+ ),
169
+ );
170
+ }
171
+
94
172
  /** `visible: false` is `Passive`, which is how the spec spells "hidden". */
95
173
  function statusOf(options) {
96
174
  if (options?.visible === false) return 'Passive';
@@ -129,8 +207,13 @@ function iconOf(icon, decode) {
129
207
  * in quick succession cannot put two registrations in flight.
130
208
  */
131
209
  export class StatusNotifierItem {
132
- constructor({ getOptions, appId, decodeIcon, onError, slot } = {}) {
210
+ constructor({ getOptions, app, appId, decodeIcon, onError, slot } = {}) {
133
211
  this.getOptions = getOptions ?? (() => null);
212
+ // The display the tray is on — not for the protocol, which is D-Bus
213
+ // only, but for a click's position: its scale, its monitors and its
214
+ // pointer are what put the host's numbers into logical pixels. Null is
215
+ // no display to ask, and a click's numbers are then passed as sent.
216
+ this.app = app ?? null;
134
217
  this.appId = appId ?? 'react-x11';
135
218
  this.decodeIcon = decodeIcon ?? (() => null);
136
219
  this.onError = onError ?? (() => {});
@@ -453,27 +536,91 @@ export class StatusNotifierItem {
453
536
  if (this.menu) this.menu.update(next.menu ?? []);
454
537
  }
455
538
 
539
+ // ------------------------------------------------------------------ click
540
+
541
+ /**
542
+ * A click's `(x, y)` in logical screen pixels — the unit a `<popup>`'s
543
+ * `x`/`y` and `anchor={{ rect }}` take. Read both ways and settled by the
544
+ * monitors, then the pointer, then as sent: the header has why.
545
+ */
546
+ async clickPoint(x, y) {
547
+ const scale = scaleOf(this.app);
548
+ if (scale === 1) return { x, y };
549
+ const readings = clickReadings(
550
+ x,
551
+ y,
552
+ scale,
553
+ screensSnapshot(this.app).screens,
554
+ );
555
+ if (readings.length === 1) return readings[0].logical;
556
+ const pointer = readings.length ? await this.queryPointer() : null;
557
+ if (!pointer) return { x, y };
558
+ let near = null;
559
+ let nearest = CLICK_REACH * scale;
560
+ for (const reading of readings) {
561
+ const d = Math.hypot(
562
+ reading.device.x - pointer.x,
563
+ reading.device.y - pointer.y,
564
+ );
565
+ if (d <= nearest) {
566
+ near = reading;
567
+ nearest = d;
568
+ }
569
+ }
570
+ return near?.logical ?? { x, y };
571
+ }
572
+
573
+ /**
574
+ * Where the pointer is on the X screen, in device pixels, or null where
575
+ * nothing can say: a backend with no X server behind it, a failed request,
576
+ * a pointer on another screen.
577
+ */
578
+ queryPointer() {
579
+ const X = this.app?.X;
580
+ const root = X?.display?.screen?.[0]?.root;
581
+ if (typeof X?.QueryPointer !== 'function' || root == null) {
582
+ return Promise.resolve(null);
583
+ }
584
+ return new Promise((resolve) => {
585
+ try {
586
+ X.QueryPointer(root, (err, reply) =>
587
+ resolve(
588
+ err || !reply?.sameScreen
589
+ ? null
590
+ : { x: reply.rootX, y: reply.rootY },
591
+ ),
592
+ );
593
+ } catch {
594
+ resolve(null);
595
+ }
596
+ });
597
+ }
598
+
456
599
  // --------------------------------------------------------------- protocol
457
600
 
458
601
  defineItem(dbus) {
459
602
  const opts = () => this.options;
460
603
  const icon = () => iconOf(opts().icon, this.decodeIcon);
461
- const click = (button) => (args) => {
462
- // No click count, no modifiers, no item rect: the protocol has none of
463
- // them. Reported as zero rather than invented see the header.
464
- opts().onClick?.({
465
- button,
466
- x: args?.x ?? 0,
467
- y: args?.y ?? 0,
468
- width: 0,
469
- height: 0,
470
- clickCount: 1,
471
- shift: false,
472
- control: false,
473
- option: false,
474
- command: false,
604
+ // No click count, no modifiers, no item rect: the protocol has none of
605
+ // them. Reported as zero rather than invented see the header. The
606
+ // position it does have is put into logical pixels first, and the call is
607
+ // answered once the app has had the click, so a throw in `onClick` still
608
+ // reaches the host as the error it was.
609
+ const click = (button) => (args) =>
610
+ this.clickPoint(args?.x ?? 0, args?.y ?? 0).then(({ x, y }) => {
611
+ opts().onClick?.({
612
+ button,
613
+ x,
614
+ y,
615
+ width: 0,
616
+ height: 0,
617
+ clickCount: 1,
618
+ shift: false,
619
+ control: false,
620
+ option: false,
621
+ command: false,
622
+ });
475
623
  });
476
- };
477
624
 
478
625
  return dbus.defineInterface({
479
626
  name: ITEM_IFACE,
package/src/styles.js CHANGED
@@ -247,6 +247,12 @@ const PAINT_PROPS = new Set([
247
247
  'outlineWidth',
248
248
  'outlineColor',
249
249
  'outlineOffset',
250
+ // How opaque the node is **with everything in it**: the subtree is drawn
251
+ // once and composited at this alpha (`NodePaint._paintGroup`), so a card, its
252
+ // border, its icon and its text fade as one — the thing a colour's own
253
+ // alpha cannot do for more than one flat fill (#587). A number from 0 to 1,
254
+ // which transitions and loops like any other.
255
+ 'opacity',
250
256
  ]);
251
257
 
252
258
  // Text style props. All affect measurement except color.
@@ -275,8 +281,105 @@ export const TEXT_LAYOUT_PROPS = new Set([
275
281
  // fits on a line, so it is a measurement input like the rest of this set.
276
282
  'textOverflow',
277
283
  'maxLines',
284
+ // CSS's letter-spacing, in px: added after every character, the last on a
285
+ // line included, which is how both text engines add it (#588)
286
+ 'letterSpacing',
287
+ // Figures and the rest of the OpenType features. `fontVariantNumeric` is
288
+ // CSS's keywords for the numeric ones — `tabular-nums` for a readout that
289
+ // holds its width while it changes — and `fontFeatureSettings` names any
290
+ // feature by tag, winning over the keyword where the two meet, as in CSS
291
+ 'fontVariantNumeric',
292
+ 'fontFeatureSettings',
278
293
  ]);
279
294
 
295
+ /**
296
+ * `fontVariantNumeric` keywords → the OpenType feature each one turns on, and
297
+ * the group it belongs to. CSS allows several keywords at once but one from
298
+ * each group, since the two in a group contradict each other: `'tabular-nums
299
+ * slashed-zero'` is a value, `'lining-nums oldstyle-nums'` is not.
300
+ */
301
+ const NUMERIC_VARIANTS = {
302
+ 'lining-nums': ['lnum', 'figures'],
303
+ 'oldstyle-nums': ['onum', 'figures'],
304
+ 'proportional-nums': ['pnum', 'spacing'],
305
+ 'tabular-nums': ['tnum', 'spacing'],
306
+ 'diagonal-fractions': ['frac', 'fractions'],
307
+ 'stacked-fractions': ['afrc', 'fractions'],
308
+ ordinal: ['ordn', 'ordinal'],
309
+ 'slashed-zero': ['zero', 'slashed-zero'],
310
+ };
311
+
312
+ /** The feature tags a `fontVariantNumeric` value turns on, or null when the
313
+ * value is not one CSS would accept. `'normal'` turns none on. */
314
+ function numericVariantTags(value) {
315
+ if (typeof value !== 'string') return null;
316
+ const words = value.trim().split(/\s+/);
317
+ if (words.length === 1 && words[0] === 'normal') return [];
318
+ const tags = [];
319
+ const groups = new Set();
320
+ for (const word of words) {
321
+ const [tag, group] = NUMERIC_VARIANTS[word] ?? [];
322
+ if (!tag || groups.has(group)) return null;
323
+ groups.add(group);
324
+ tags.push(tag);
325
+ }
326
+ return tags;
327
+ }
328
+
329
+ /** An OpenType feature tag: four printable ASCII characters. */
330
+ const FEATURE_TAG = /^[\x20-\x7e]{4}$/;
331
+
332
+ /** Whether `value` is a `fontFeatureSettings`: an array of tags to turn on,
333
+ * or an object of tag → on or off, or the number of the alternate a feature
334
+ * picks. */
335
+ function isFeatureSettings(value) {
336
+ if (Array.isArray(value)) {
337
+ return value.every(
338
+ (tag) => typeof tag === 'string' && FEATURE_TAG.test(tag),
339
+ );
340
+ }
341
+ if (!value || typeof value !== 'object') return false;
342
+ return Object.entries(value).every(
343
+ ([tag, v]) =>
344
+ FEATURE_TAG.test(tag) &&
345
+ (typeof v === 'boolean' || (Number.isInteger(v) && v >= 0)),
346
+ );
347
+ }
348
+
349
+ // the resolved `features` for one (variant, settings) pair, kept so that the
350
+ // same pair is the same object every time: a cascade compares two resolved
351
+ // styles with `!==` (`resolvedTextDelta`), and a fresh object per node would
352
+ // read as a change and re-measure text that did not move
353
+ const resolvedFeatures = new Map();
354
+
355
+ /**
356
+ * What the text engines are handed: every feature the two properties set, as
357
+ * tag → value (1 on, 0 off, or the alternate), `fontFeatureSettings` over
358
+ * the keywords. `undefined` when neither says anything, which is nearly
359
+ * every node.
360
+ */
361
+ export function featuresOf(variantNumeric, featureSettings) {
362
+ if (variantNumeric === undefined && featureSettings === undefined) {
363
+ return undefined;
364
+ }
365
+ const key = `${variantNumeric ?? ''}\u0000${JSON.stringify(featureSettings ?? null)}`;
366
+ if (resolvedFeatures.has(key)) return resolvedFeatures.get(key);
367
+ const features = {};
368
+ for (const tag of numericVariantTags(variantNumeric) ?? []) features[tag] = 1;
369
+ if (Array.isArray(featureSettings)) {
370
+ for (const tag of featureSettings) features[tag] = 1;
371
+ } else if (featureSettings) {
372
+ for (const [tag, value] of Object.entries(featureSettings)) {
373
+ features[tag] = value === true ? 1 : value === false ? 0 : value;
374
+ }
375
+ }
376
+ const out =
377
+ Object.keys(features).length > 0 ? Object.freeze(features) : undefined;
378
+ if (resolvedFeatures.size > 256) resolvedFeatures.clear();
379
+ resolvedFeatures.set(key, out);
380
+ return out;
381
+ }
382
+
280
383
  /**
281
384
  * Text style props that change how the text is **drawn** and provably not
282
385
  * where any of it lands. They still invalidate the cached layout — the value
@@ -298,7 +401,8 @@ export const TEXT_PAINT_PROPS = new Set(['textRendering']);
298
401
  * dims the labels under it the way it would in CSS.
299
402
  *
300
403
  * This is CSS's inherited set narrowed to what a *descendant* can act on: the
301
- * face, the size, the ink and the glyph rounding. `textAlign`, `lineHeight`,
404
+ * face, the size, the ink, the glyph rounding, the spacing between letters
405
+ * and the OpenType features. `textAlign`, `lineHeight`,
302
406
  * `textWrap`, `textOverflow`, `maxLines` and `textBoxTrim` stay out even
303
407
  * though CSS inherits the first two — here they are read by the node that
304
408
  * owns the **box** the text flows in, and a box is not something a descendant
@@ -313,6 +417,9 @@ export const INHERITED_TEXT_PROPS = new Set([
313
417
  'fontVariationSettings',
314
418
  'textRendering',
315
419
  'color',
420
+ 'letterSpacing',
421
+ 'fontVariantNumeric',
422
+ 'fontFeatureSettings',
316
423
  ]);
317
424
 
318
425
  /**
@@ -340,7 +447,7 @@ export function localTextStyleChanged(style, before) {
340
447
  export function inheritedTextChanged(style, before) {
341
448
  if (style === before) return false;
342
449
  for (const key of INHERITED_TEXT_PROPS) {
343
- if (key === 'fontVariationSettings') {
450
+ if (key === 'fontVariationSettings' || key === 'fontFeatureSettings') {
344
451
  if (!axesEqual(style[key], before[key])) return true;
345
452
  } else if (style[key] !== before[key]) return true;
346
453
  }
@@ -370,7 +477,9 @@ export function resolvedTextDelta(a, b) {
370
477
  a.size !== b.size ||
371
478
  a.weight !== b.weight ||
372
479
  a.style !== b.style ||
373
- !axesEqual(a.variations, b.variations)
480
+ !axesEqual(a.variations, b.variations) ||
481
+ a.letterSpacing !== b.letterSpacing ||
482
+ a.features !== b.features
374
483
  ) {
375
484
  return TEXT_REMEASURE;
376
485
  }
@@ -746,6 +855,64 @@ const GRID_VALUES = new Set([...GRID_CONTAINER_PROPS, ...GRID_ITEM_PROPS]);
746
855
  * grammar is concerned), so this checks the shape and never the colours.
747
856
  */
748
857
  function validateValue(key, value, where) {
858
+ if (key === 'letterSpacing') {
859
+ if (
860
+ value != null &&
861
+ !(typeof value === 'number' && Number.isFinite(value)) &&
862
+ !isToken(value)
863
+ ) {
864
+ throw new Error(
865
+ `react-x11: invalid letterSpacing ${JSON.stringify(value)} in ${where} ` +
866
+ '(expected a number of pixels, like letterSpacing: 1.5, or a $token)',
867
+ );
868
+ }
869
+ return;
870
+ }
871
+ if (key === 'fontVariantNumeric') {
872
+ if (
873
+ value != null &&
874
+ numericVariantTags(value) === null &&
875
+ !isToken(value)
876
+ ) {
877
+ throw new Error(
878
+ `react-x11: invalid fontVariantNumeric ${JSON.stringify(value)} in ` +
879
+ `${where} (expected 'normal', or keywords from ` +
880
+ `${Object.keys(NUMERIC_VARIANTS).join(', ')} with at most one of ` +
881
+ 'lining/oldstyle, proportional/tabular and diagonal/stacked, like ' +
882
+ "'tabular-nums' or 'tabular-nums slashed-zero')",
883
+ );
884
+ }
885
+ return;
886
+ }
887
+ if (key === 'fontFeatureSettings') {
888
+ if (value != null && !isFeatureSettings(value) && !isToken(value)) {
889
+ throw new Error(
890
+ `react-x11: invalid fontFeatureSettings ${JSON.stringify(value)} in ` +
891
+ `${where} (expected four-letter OpenType tags — an array of tags to ` +
892
+ "turn on, ['tnum'], or an object of tag to on/off or an alternate, " +
893
+ '{ tnum: true, liga: false, salt: 2 })',
894
+ );
895
+ }
896
+ return;
897
+ }
898
+ if (key === 'opacity') {
899
+ // unset, the way a conditional writes it: `opacity: dim ? 0.5 : undefined`
900
+ if (value == null) return;
901
+ // A string that is not a token is the one mistake worth stopping for:
902
+ // `'50%'` or `'0.5'` would otherwise compare as not-below-1 and paint
903
+ // the node fully opaque, silently. A number outside 0..1 is clamped
904
+ // where it is painted, as CSS clamps it.
905
+ if (
906
+ !(typeof value === 'number' && !Number.isNaN(value)) &&
907
+ !isToken(value)
908
+ ) {
909
+ throw new Error(
910
+ `react-x11: invalid opacity ${JSON.stringify(value)} in ${where} ` +
911
+ '(expected a number from 0 to 1 — opacity: 0.5 is half — or a $token)',
912
+ );
913
+ }
914
+ return;
915
+ }
749
916
  const grid = GRID_VALUES.has(key);
750
917
  if (!grid && key !== 'backgroundImage' && key !== 'boxShadow') return;
751
918
  try {
@@ -962,6 +1129,9 @@ const NOT_ANIMATABLE = new Set([
962
1129
  'fontFamily',
963
1130
  'fontWeight',
964
1131
  'fontStyle',
1132
+ // a keyword and a bag of tags: there is no halfway figure
1133
+ 'fontVariantNumeric',
1134
+ 'fontFeatureSettings',
965
1135
  'textAlign',
966
1136
  // nothing is drawn from it, so there is no frame in which a halfway value
967
1137
  // would be visible — and it may be an object, which does not lerp
@@ -1056,6 +1226,9 @@ const parsedAnimations = new WeakMap();
1056
1226
  * a thing that does not move: a loop nobody wrote a stop for is exactly the
1057
1227
  * feature where silence is unreadable.
1058
1228
  */
1229
+ /** What one property's loop may say. */
1230
+ const LOOP_OPTIONS = ['from', 'to', 'duration', 'easing', 'alternate', 'delay'];
1231
+
1059
1232
  function parseAnimation(spec, where) {
1060
1233
  if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) {
1061
1234
  throw new Error(
@@ -1077,15 +1250,15 @@ function parseAnimation(spec, where) {
1077
1250
  if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
1078
1251
  throw new Error(
1079
1252
  `react-x11: invalid animation for ${at} — expected ` +
1080
- '{ from?, to, duration, easing?, alternate? }, got ' +
1253
+ '{ from?, to, duration, easing?, alternate?, delay? }, got ' +
1081
1254
  JSON.stringify(entry),
1082
1255
  );
1083
1256
  }
1084
1257
  for (const key of Object.keys(entry)) {
1085
- if (!['from', 'to', 'duration', 'easing', 'alternate'].includes(key)) {
1258
+ if (!LOOP_OPTIONS.includes(key)) {
1086
1259
  throw new Error(
1087
1260
  `react-x11: unknown animation option "${key}" for ${at} ` +
1088
- '(expected from, to, duration, easing, alternate)',
1261
+ `(expected ${LOOP_OPTIONS.join(', ')})`,
1089
1262
  );
1090
1263
  }
1091
1264
  }
@@ -1109,6 +1282,14 @@ function parseAnimation(spec, where) {
1109
1282
  `${at} (expected one of ${EASING_NAMES.join(', ')})`,
1110
1283
  );
1111
1284
  }
1285
+ const delay = entry.delay ?? 0;
1286
+ if (typeof delay !== 'number' || !Number.isFinite(delay)) {
1287
+ throw new Error(
1288
+ `react-x11: animation for ${at} needs "delay" in ms — before the ` +
1289
+ 'loop starts, or negative to start it that far in — got ' +
1290
+ JSON.stringify(entry.delay),
1291
+ );
1292
+ }
1112
1293
  entries.push({
1113
1294
  prop,
1114
1295
  from: entry.from,
@@ -1117,6 +1298,7 @@ function parseAnimation(spec, where) {
1117
1298
  easing,
1118
1299
  ease: EASINGS[easing],
1119
1300
  alternate: Boolean(entry.alternate),
1301
+ delay,
1120
1302
  });
1121
1303
  }
1122
1304
  return entries;
@@ -1184,7 +1366,8 @@ export function sameAnimation(a, b) {
1184
1366
  a.to === b.to &&
1185
1367
  a.duration === b.duration &&
1186
1368
  a.easing === b.easing &&
1187
- a.alternate === b.alternate
1369
+ a.alternate === b.alternate &&
1370
+ a.delay === b.delay
1188
1371
  );
1189
1372
  }
1190
1373
 
@@ -1193,9 +1376,14 @@ export function sameAnimation(a, b) {
1193
1376
  * the looping: the phase comes from a modulo of the elapsed time rather than
1194
1377
  * from a per-cycle restart, so a bar that has been spinning for an hour is
1195
1378
  * exactly where the clock says and no rounding has accumulated.
1379
+ *
1380
+ * The delay is where the loop's own time starts: `from` until a positive one
1381
+ * has passed, and already that far in for a negative one — CSS's
1382
+ * `animation-delay` with `animation-fill-mode: backwards`, which is also
1383
+ * what Core Animation shows for the same declaration.
1196
1384
  */
1197
1385
  export function animationValueAt(spec, elapsed) {
1198
- const cycles = Math.max(0, elapsed) / spec.duration;
1386
+ const cycles = Math.max(0, elapsed - spec.delay) / spec.duration;
1199
1387
  let t = cycles % 1;
1200
1388
  if (spec.alternate && Math.floor(cycles) % 2 === 1) t = 1 - t;
1201
1389
  return interpolate(spec.from, spec.to, spec.ease(t)) ?? spec.from;
@@ -1670,6 +1858,7 @@ export const SCALED_LENGTH_PROPS = [
1670
1858
  'outlineWidth',
1671
1859
  'outlineOffset',
1672
1860
  'fontSize',
1861
+ 'letterSpacing',
1673
1862
  ];
1674
1863
 
1675
1864
  const SCALED_LENGTHS = new Set(SCALED_LENGTH_PROPS);
@@ -1834,6 +2023,9 @@ export function paintPropsChanged(props, oldProps = {}) {
1834
2023
 
1835
2024
  /** Resolved text style (TextLayout base style) from props + inherited. */
1836
2025
  export function textStyleFrom(props, inherited) {
2026
+ const variantNumeric = props.fontVariantNumeric ?? inherited.variantNumeric;
2027
+ const featureSettings =
2028
+ props.fontFeatureSettings ?? inherited.featureSettings;
1837
2029
  return {
1838
2030
  family: props.fontFamily ?? inherited.family,
1839
2031
  size: props.fontSize ?? inherited.size,
@@ -1844,6 +2036,14 @@ export function textStyleFrom(props, inherited) {
1844
2036
  variations: props.fontVariationSettings ?? inherited.variations,
1845
2037
  textRendering: props.textRendering ?? inherited.textRendering,
1846
2038
  color: props.color ?? inherited.color,
2039
+ letterSpacing: props.letterSpacing ?? inherited.letterSpacing,
2040
+ // Inherited apart, as CSS inherits the two properties apart — a child's
2041
+ // `fontVariantNumeric` replaces the keyword above it and leaves an
2042
+ // inherited `fontFeatureSettings` alone — and resolved together into what
2043
+ // the engines read.
2044
+ variantNumeric,
2045
+ featureSettings,
2046
+ features: featuresOf(variantNumeric, featureSettings),
1847
2047
  };
1848
2048
  }
1849
2049
 
@@ -1865,6 +2065,10 @@ export const DEFAULT_TEXT_STYLE = {
1865
2065
  variations: undefined,
1866
2066
  textRendering: undefined,
1867
2067
  color: 'black',
2068
+ letterSpacing: undefined,
2069
+ variantNumeric: undefined,
2070
+ featureSettings: undefined,
2071
+ features: undefined,
1868
2072
  };
1869
2073
 
1870
2074
  /**