react-x11 2.2.0 → 2.3.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/src/nodes.js CHANGED
@@ -109,7 +109,12 @@ import {
109
109
  windowOrigin,
110
110
  } from './anchor.js';
111
111
  import { baseTheme } from './palette.js';
112
- import { callHandler, ownerName } from './errors.js';
112
+ import {
113
+ callHandler,
114
+ ownerName,
115
+ reportStyleError,
116
+ STRICT_TOKENS,
117
+ } from './errors.js';
113
118
  import {
114
119
  hooks as a11yHooks,
115
120
  isFocusable as a11yFocusable,
@@ -1542,6 +1547,13 @@ export class Node {
1542
1547
  // delete themselves as they land; a loop entry (`loop: true`) is removed
1543
1548
  // by `_updateLoops` and by nothing else
1544
1549
  this._anim = null;
1550
+ // False until the first frame places this node (`absolutize`). Read by
1551
+ // `_retarget`: a style can be re-resolved several times between
1552
+ // construction and that first frame — the attach-time theme merge is the
1553
+ // common one, replacing a detached resolution against the desktop
1554
+ // palette with one against the app's own — and none of those is a
1555
+ // *change* the user saw, so no transition may start from it.
1556
+ this._placed = false;
1545
1557
  // the loops this node's style declares, whether or not they are running
1546
1558
  this._loops = null;
1547
1559
  // `resolvedTextStyle()`'s cache: this node's own text style over what it
@@ -1557,6 +1569,11 @@ export class Node {
1557
1569
  // subtree's hit reach, invalidated through _clearHitBounds()
1558
1570
  this._paintOrderCache = null;
1559
1571
  this._hitBoundsCache = null;
1572
+ // a `$token` the theme does not define, held for `commitMount` to throw
1573
+ // on this node's own fiber — see `_tokenProblem`. Strict mode only.
1574
+ // `null` is "commitMount is still to come", `false` is "it has been and
1575
+ // gone", and an Error is one waiting for it
1576
+ this._tokenError = null;
1560
1577
  this._syncStyle(props);
1561
1578
  this.yoga = yoga ? createLayoutNode() : null;
1562
1579
  if (this.yoga) {
@@ -1588,7 +1605,7 @@ export class Node {
1588
1605
  * them. `baseStyle` is the flattened `style` prop; `style` is that with
1589
1606
  * the active state blocks overlaid.
1590
1607
  */
1591
- _syncStyle(props) {
1608
+ _syncStyle(props, mounting = false) {
1592
1609
  if (DEV && this.stylable) {
1593
1610
  assertNoFlatStyleProps(props, this.kind, this.semanticNames);
1594
1611
  validateStyle(flattenStyle(props.style), `<${this.kind} style>`);
@@ -1597,12 +1614,16 @@ export class Node {
1597
1614
  this._usesTokens = this.stylable && styleUsesTokens(this._baseStyle);
1598
1615
  if (this._usesTokens) {
1599
1616
  const theme = this.theme;
1617
+ const strict = this.placed;
1618
+ const problems = strict ? [] : null;
1600
1619
  this._baseStyle = resolveTokens(
1601
1620
  this._baseStyle,
1602
1621
  theme,
1603
1622
  `<${this.kind} style>`,
1604
- this.placed,
1623
+ strict,
1624
+ problems,
1605
1625
  );
1626
+ if (problems?.length) this._tokenProblem(problems, mounting);
1606
1627
  }
1607
1628
  // `disabled` is a prop, not something the pointer does, so it is read
1608
1629
  // straight off props rather than driven by the event manager
@@ -1687,24 +1708,32 @@ export class Node {
1687
1708
  }
1688
1709
  return this.style;
1689
1710
  }
1690
- for (const prop of Object.keys(target)) {
1691
- const to = target[prop];
1692
- const from = displayed[prop];
1693
- if (from === to || from === undefined) continue;
1694
- const duration = transitionFor(target, prop);
1695
- if (duration <= 0) continue;
1696
- if (interpolate(from, to, 0.5) === null) continue; // no midpoint: snap
1697
- (this._anim ??= new Map()).set(prop, {
1698
- from,
1699
- to,
1700
- duration,
1701
- // *now*, not the last frame's timestamp: between two user actions
1702
- // the window is idle and draws nothing, so the previous frame can
1703
- // be seconds old and the first tick would then find the
1704
- // transition already over and jump straight to the end
1705
- start: now(),
1706
- });
1707
- this.root?._startAnimating(this);
1711
+ // Only for a node the user has seen (`_placed`): between construction
1712
+ // and the first frame a style is re-resolved several times — attach
1713
+ // merges the real theme over the detached resolution's desktop palette,
1714
+ // queries settle and animating any of those would travel from a value
1715
+ // that was never on screen. An inserted element *appears* at its style;
1716
+ // transitions start on later changes, which is CSS's rule too.
1717
+ if (this._placed) {
1718
+ for (const prop of Object.keys(target)) {
1719
+ const to = target[prop];
1720
+ const from = displayed[prop];
1721
+ if (from === to || from === undefined) continue;
1722
+ const duration = transitionFor(target, prop);
1723
+ if (duration <= 0) continue;
1724
+ if (interpolate(from, to, 0.5) === null) continue; // no midpoint: snap
1725
+ (this._anim ??= new Map()).set(prop, {
1726
+ from,
1727
+ to,
1728
+ duration,
1729
+ // *now*, not the last frame's timestamp: between two user actions
1730
+ // the window is idle and draws nothing, so the previous frame can
1731
+ // be seconds old — and the first tick would then find the
1732
+ // transition already over and jump straight to the end
1733
+ start: now(),
1734
+ });
1735
+ this.root?._startAnimating(this);
1736
+ }
1708
1737
  }
1709
1738
  // After the transitions, before the style is assembled: a loop that just
1710
1739
  // arrived contributes a value to this very swap, so the first frame the
@@ -2241,6 +2270,46 @@ export class Node {
2241
2270
  return owner.isPopup ? owner.parent != null : true;
2242
2271
  }
2243
2272
 
2273
+ /**
2274
+ * A `$token` this node's completed ancestry does not define.
2275
+ *
2276
+ * The default is `reportStyleError`: say so loudly, set `process.exitCode`,
2277
+ * and keep the property dropped. `REACT_X11_STRICT_TOKENS=1` makes it fatal
2278
+ * again, and then *where* the throw lands is the whole question — an error
2279
+ * boundary only catches what React invoked, on the fiber React thinks it
2280
+ * is working on.
2281
+ *
2282
+ * `mounting` is the attach walk, which runs inside `appendInitialChild`
2283
+ * while React is completing the nearest host *ancestor* — the `<window>`,
2284
+ * for a whole tree rendered at once. A throw there is attributed to the
2285
+ * window and sails past every boundary the app wrote inside it, which is
2286
+ * the bug this deferral exists for (#420). Stashed instead, and thrown
2287
+ * from `commitMount` on this node's own fiber, where the walk up finds a
2288
+ * boundary at any depth.
2289
+ *
2290
+ * Every other caller already has the right fiber (`commitUpdate`) or has
2291
+ * no React on the stack at all (`appearanceChanged`, from an X event) —
2292
+ * for those, throwing here is both the earliest and the only option, and
2293
+ * the second is the crash strict mode asked for.
2294
+ *
2295
+ * `commitMount` happens once per instance, so a node re-attached after it
2296
+ * has been and gone has nothing left to defer *to*; stashing there would
2297
+ * swallow the error instead of raising it late. Those throw at once, like
2298
+ * the keyed reorder they resemble.
2299
+ */
2300
+ _tokenProblem(problems, mounting) {
2301
+ if (!STRICT_TOKENS) {
2302
+ // every one of them: two misspellings in a style are two things to
2303
+ // fix, and a report that named only the first would send someone back
2304
+ // for a second run to find the second
2305
+ for (const message of problems) reportStyleError(this, message);
2306
+ return;
2307
+ }
2308
+ const error = new Error(problems[0]);
2309
+ if (mounting && this._tokenError === null) this._tokenError = error;
2310
+ else throw error;
2311
+ }
2312
+
2244
2313
  /** The owning window resized: re-resolve, since a query block may now
2245
2314
  * match that did not, or the other way round. */
2246
2315
  _sizeQueriesChanged() {
@@ -2287,7 +2356,7 @@ export class Node {
2287
2356
  if (this.isWindow) this._syncWindowBackground();
2288
2357
  if (this._usesTokens) {
2289
2358
  const before = this.style;
2290
- this._syncStyle(this.props);
2359
+ this._syncStyle(this.props, mounting);
2291
2360
  // a token change reaches the node without React re-rendering it, so
2292
2361
  // the invalidation a commit would have done has to happen here too
2293
2362
  if (localTextStyleChanged(this.style, before)) {
@@ -3199,6 +3268,9 @@ export class Node {
3199
3268
  }
3200
3269
 
3201
3270
  absolutize(originX, originY) {
3271
+ // before the yoga check, so a span — placed by its paragraph, no box of
3272
+ // its own — counts as on screen too
3273
+ this._placed = true;
3202
3274
  if (!this.yoga) return;
3203
3275
  this._assignAbs(
3204
3276
  originX + this.yoga.getComputedLeft(),
@@ -4122,19 +4194,28 @@ export class Node {
4122
4194
  * One blurred shadow, through the paint cache when there is one.
4123
4195
  *
4124
4196
  * The surface is the shadow's rectangle plus `pad` on every side, and the
4125
- * padding is load-bearing: the convolution reads outside the picture as
4197
+ * padding is load-bearing: a convolution reads outside the picture as
4126
4198
  * transparent, so a kernel that runs off the edge ends the shadow in a
4127
- * straight line. The blur is set on the *picture* rather than baked into
4128
- * the pixels, which is why it survives in a cached entry and why the
4129
- * surface itself is a plain white rectangle.
4199
+ * straight line. `blurKernel` takes that reach from the same function
4200
+ * ntk builds the kernel with, so the two cannot drift apart.
4201
+ *
4202
+ * The blur is **baked into the pixels** by `blurCoverage` (ntk 8.6,
4203
+ * ntk#335) rather than set as a filter on the picture. That is the
4204
+ * difference between a cached shadow and a cached shadow that costs
4205
+ * nothing to draw: a picture's filter is re-applied by the server on every
4206
+ * composite, so the entry would hit, re-render nothing, and still pay its
4207
+ * whole kernel every frame — 244M multiply-accumulates for one card-sized
4208
+ * shadow, which was 1.6s per `:hover` on XQuartz. Baked, what the cache
4209
+ * holds composites as an ordinary mask however wide the blur was, and the
4210
+ * two separable passes run once per distinct geometry.
4130
4211
  *
4131
4212
  * `maxPixels` is raised well above the cache's default: a card's shadow is
4132
4213
  * as big as the card, an entry for one is a8 (a byte a pixel), and the
4133
- * thing being avoided a convolution per frame over the whole box — is
4134
- * exactly the cost the default cap exists to bound elsewhere.
4214
+ * thing being avoided is exactly the cost the default cap bounds
4215
+ * elsewhere.
4135
4216
  */
4136
4217
  _paintBlurredShadow(ctx, rect, radius, blur, color) {
4137
- const { sigma, size, pad } = blurKernel(blur);
4218
+ const { sigma, pad } = blurKernel(blur);
4138
4219
  // integral, because the surface is pixels; the blur is far wider than
4139
4220
  // the rounding, so nothing about the result is visibly quantized
4140
4221
  const width = Math.round(rect.width);
@@ -4148,6 +4229,10 @@ export class Node {
4148
4229
  format: 'a8',
4149
4230
  tint: color,
4150
4231
  maxPixels: 1024 * 1024,
4232
+ // Cache on the first sighting rather than the second: what the gate
4233
+ // saves elsewhere is a cheap redraw, and what it costs here is a whole
4234
+ // gaussian — the one thing this entry exists to avoid running twice.
4235
+ eager: true,
4151
4236
  draw: (sctx, box) => {
4152
4237
  // full coverage: the colour arrives at composite time
4153
4238
  sctx.fillStyle = '#ffffff';
@@ -4158,7 +4243,7 @@ export class Node {
4158
4243
  );
4159
4244
  sctx.fill();
4160
4245
  },
4161
- after: (surface) => surface.picture().setBlurFilter(size, sigma),
4246
+ after: (surface) => ntk.blurCoverage(surface, sigma),
4162
4247
  live: () => this._paintShadowLive(ctx, plan),
4163
4248
  };
4164
4249
  const cache = this.root?._paintCache;
@@ -4171,7 +4256,9 @@ export class Node {
4171
4256
  * build, an entry too big for the budget, and the first frame of a shadow
4172
4257
  * the cache has only seen once. A surface per frame is what a shadow costs
4173
4258
  * without a cache; it is still one composite on the wire, and the
4174
- * alternative is not painting it.
4259
+ * alternative is not painting it. The blur is baked here too: two
4260
+ * separable passes and a plain composite still beat one composite through
4261
+ * a k x k kernel, by the ratio of 2k to k squared.
4175
4262
  */
4176
4263
  _paintShadowLive(ctx, plan) {
4177
4264
  if (typeof ntk.Surface !== 'function' || !this.app?.display?.Render) return;
@@ -4185,7 +4272,10 @@ export class Node {
4185
4272
  surface.render((sctx) =>
4186
4273
  plan.draw(sctx, { x: 0, y: 0, width: plan.width, height: plan.height }),
4187
4274
  );
4188
- plan.after(surface);
4275
+ // `after` may hand back a *different* surface — the blur is baked into
4276
+ // a second one and the sharp copy destroyed — so both the drawing and
4277
+ // the cleanup below follow what it returned.
4278
+ surface = plan.after(surface) ?? surface;
4189
4279
  const before = ctx.fillStyle;
4190
4280
  ctx.fillStyle = plan.tint;
4191
4281
  ctx.drawImage(surface, plan.x, plan.y);
@@ -5425,6 +5515,7 @@ export const Scrollable = (Base) =>
5425
5515
  }
5426
5516
 
5427
5517
  absolutize(originX, originY) {
5518
+ this._placed = true;
5428
5519
  if (!this.yoga) return;
5429
5520
  this._assignAbs(
5430
5521
  originX + this.yoga.getComputedLeft(),
@@ -6476,13 +6567,16 @@ export function openEditMenu(node, at, actions = {}) {
6476
6567
  if (items.length === 0) return;
6477
6568
 
6478
6569
  const style = node.resolvedTextStyle();
6570
+ // `at` is `{x: ev.x, y: ev.y}` per the doc above — logical, like every
6571
+ // coordinate a handler reads — and everything below is device: the
6572
+ // geometry takes the scale so its chrome lands on the same grid as the
6573
+ // device-sized text it measures.
6574
+ const s = node.scale;
6479
6575
  const geometry = editMenuGeometry(
6480
6576
  items,
6481
6577
  (text) => app?.fonts?.layout(text, style)?.width,
6578
+ s,
6482
6579
  );
6483
- // `at` is `{x: ev.x, y: ev.y}` per the doc above — logical, like every
6484
- // coordinate a handler reads — and the origin math below is device.
6485
- const s = node.scale;
6486
6580
  const deviceAt = at && {
6487
6581
  ...at,
6488
6582
  ...(Number.isFinite(at.x) && { x: at.x * s }),
@@ -10173,8 +10267,20 @@ export class WindowNode extends Scrollable(Node) {
10173
10267
  * before laying out. This is the whole reason a size query may carry
10174
10268
  * layout properties while a state block may not: it only ever runs inside
10175
10269
  * a layout pass the resize already required.
10176
- */
10177
- _resolveSizeQueries(width, height) {
10270
+ *
10271
+ * Callers pass the size in device pixels — it comes off the window or out
10272
+ * of yoga, and both live on the device grid — but `querySize` is stored
10273
+ * in **logical** pixels, because that is the unit the thresholds were
10274
+ * written in: `'@width >= 620'` sits in a style block next to `width:
10275
+ * 620`, and the same number must mean the same thing. At scale 1 the two
10276
+ * coincide, which is how comparing device pixels survived every 1x
10277
+ * display it was ever run on and broke on the first retina one (every
10278
+ * query read double, so none of them ever changed answer under a drag).
10279
+ */
10280
+ _resolveSizeQueries(deviceWidth, deviceHeight) {
10281
+ const s = this.scale || 1;
10282
+ const width = deviceWidth / s;
10283
+ const height = deviceHeight / s;
10178
10284
  if (this._sizeQueryNodes.size === 0) {
10179
10285
  this.querySize = this.querySize ?? { width, height };
10180
10286
  return false;
@@ -10398,6 +10504,11 @@ export class WindowNode extends Scrollable(Node) {
10398
10504
  }
10399
10505
  (this._frameReasons ??= new Set()).add(reason);
10400
10506
  }
10507
+ // A retained presenter keeps a per-node diff instead of damage rects,
10508
+ // and this is the one channel every change already announces itself on
10509
+ // (docs/macos.md §"One renderer, two presenters"). Feature-detected: an
10510
+ // ntk window has no ear here and the X11 path is byte-identical.
10511
+ this.window?.noteInvalidate?.(damage, layoutChanged, reason);
10401
10512
  if (layoutChanged) {
10402
10513
  this.needsLayout = true;
10403
10514
  // The content floors are measured from the tree, so anything that
@@ -10562,6 +10673,7 @@ export class WindowNode extends Scrollable(Node) {
10562
10673
  this.yoga.setHeight(height);
10563
10674
  this.yoga.calculateLayout(width, height, this._rootDirection);
10564
10675
  this.abs = { x: 0, y: 0, width, height };
10676
+ this._placed = true;
10565
10677
  // the root's rect is written here, not through _assignAbs, so its
10566
10678
  // cached hit reach is dropped here too (children bubble their own)
10567
10679
  this._hitBoundsCache = null;
@@ -10637,6 +10749,16 @@ export class WindowNode extends Scrollable(Node) {
10637
10749
  );
10638
10750
  }
10639
10751
  this._fullRepaintCause = null;
10752
+ // A retained presenter takes the frame from here: the model half above —
10753
+ // animations, layout, absolutize, the scroll offsets — is shared, and
10754
+ // what changes per backend is how a frame reaches the screen. The damage
10755
+ // list was still taken (its bookkeeping is what keeps the two paths one
10756
+ // code) and is simply not consumed; the presenter diffs at the layer.
10757
+ if (typeof this.window.presentFrame === 'function') {
10758
+ this.window.presentFrame(this, damage);
10759
+ this.app._reactX11Startup?.painted();
10760
+ return;
10761
+ }
10640
10762
  if (typeof this.window.getContext !== 'function') return; // headless mock
10641
10763
  // ntk getContext creates a fresh context (with window-event
10642
10764
  // subscriptions) on every call — cache one per window
@@ -10657,6 +10779,12 @@ export class WindowNode extends Scrollable(Node) {
10657
10779
  // after every region: an entry drawn in one damage rect must not be
10658
10780
  // evicted before the next rect of the same frame asks for it
10659
10781
  this._paintCache?.endFrame();
10782
+ // The swapchain seam: a backend presenting from double buffers has to
10783
+ // know exactly which pixels each flush touched — several flushes can
10784
+ // land between two presents, so reading only the last frame's rects
10785
+ // would leave the flipped-in back buffer stale where an earlier flush
10786
+ // painted. Feature-detected like presentFrame; null means everything.
10787
+ this.window.noteFrameDamage?.(damage ?? null);
10660
10788
  if (frameHook) {
10661
10789
  frameHook({
10662
10790
  root: this,
package/src/paintcache.js CHANGED
@@ -232,7 +232,13 @@ export class PaintCache {
232
232
  }
233
233
 
234
234
  this.stats.misses++;
235
- const seen = (this.pending.get(plan.key) ?? 0) + 1;
235
+ // `eager` skips the gate below for a drawing whose *live* path is the
236
+ // expensive thing — a blurred shadow, whose first sighting otherwise
237
+ // runs a convolution that is then thrown away, and a second one to keep.
238
+ // The gate is there so a page cycling unique content cannot fill the
239
+ // cache with entries drawn once; an eager plan opts out of that
240
+ // protection deliberately, and the LRU budget is what still bounds it.
241
+ const seen = plan.eager ? 2 : (this.pending.get(plan.key) ?? 0) + 1;
236
242
  if (seen < 2) {
237
243
  if (this.pending.size >= MAX_PENDING) this.pending.clear();
238
244
  this.pending.set(plan.key, seen);
@@ -260,12 +266,17 @@ export class PaintCache {
260
266
  surface.render((sctx) =>
261
267
  plan.draw(this.verify ? recordingContext(sctx, state) : sctx, box),
262
268
  );
263
- plan.after?.(surface);
269
+ // `after` may hand back a *different* surface than it was given — a
270
+ // blurred shadow bakes its convolution into a second one and destroys
271
+ // the sharp copy, so that what this entry holds composites as a plain
272
+ // mask instead of re-running a kernel on every blit. What it returns
273
+ // is what the cache owns from here on.
274
+ const final = plan.after?.(surface) ?? surface;
264
275
  this.stats.renders++;
265
276
  return {
266
277
  key: plan.key,
267
- surface,
268
- bytes: surface.bytes,
278
+ surface: final,
279
+ bytes: final.bytes,
269
280
  digest: this.verify ? state.digest : 0,
270
281
  };
271
282
  } catch (err) {
package/src/palette.js CHANGED
@@ -222,6 +222,19 @@ export const DefaultTheme = {
222
222
  // than it looks next to a CSS padding for that reason: 12 here is about
223
223
  // what 8 came to once a typical face's ascent had been added on.
224
224
  paddingY: 12,
225
+ // Which scheme this palette *is* — 'light' or 'dark'. Not a colour but a
226
+ // fact about the colours, for the consumers that have to match them with
227
+ // something they do not paint themselves: the Cocoa backend picks the
228
+ // AppKit appearance its native control bezels are rendered in from this,
229
+ // so a pinned-light app gets light bezels on a dark desktop. A custom
230
+ // dark palette built over the light base should say `scheme: 'dark'`.
231
+ scheme: 'light',
232
+ // How the core controls render where the backend offers the platform's
233
+ // own: `'auto'` (native where supported — today the Cocoa backend),
234
+ // `'native'` (ask for it; warns and falls back to drawn where there is
235
+ // none) or `'drawn'` (always the themed rendering). Per-instance escape
236
+ // hatch: `native={false}` on the one custom-branded control.
237
+ controls: 'auto',
225
238
  };
226
239
 
227
240
  // Which pressed token is derived from which pair, when the palette does not
@@ -332,6 +345,7 @@ export function resolveTheme(value, base = DefaultTheme) {
332
345
  * theme after it gets to do.
333
346
  */
334
347
  export const DarkTheme = resolveTheme({
348
+ scheme: 'dark',
335
349
  // A near-black with a little blue in it rather than #000: pure black shows
336
350
  // every seam between a window and the widgets on it, and no desktop's dark
337
351
  // theme uses it.
package/src/style.d.ts CHANGED
@@ -43,12 +43,15 @@ export function tokenNames(
43
43
  style: StyleProperties,
44
44
  out?: Set<string>,
45
45
  ): Set<string>;
46
- /** Replace `$token` references with values from the theme. */
46
+ /** Replace `$token` references with values from the theme. A token the theme
47
+ * does not define is dropped either way; with `strict`, the message naming
48
+ * it is pushed onto `problems` for the caller to report or throw. */
47
49
  export function resolveTokens(
48
50
  style: StyleProperties,
49
51
  theme: Record<string, unknown> | null | undefined,
50
52
  where?: string,
51
53
  strict?: boolean,
54
+ problems?: string[] | null,
52
55
  ): StyleProperties;
53
56
 
54
57
  export function styleHasSizeQueries(style: StyleProperties): boolean;
package/src/styles.js CHANGED
@@ -466,6 +466,11 @@ const isState = (key) => key.charCodeAt(0) === 58; /* ':' */
466
466
  * what a style can usefully ask about here is the window it is being laid
467
467
  * out in, not the screen.
468
468
  *
469
+ * The threshold is **logical** pixels, like every other number in a style
470
+ * block: `'@width >= 620'` flips where `width: 620` would fit, whatever
471
+ * the display scale (the window node divides its device size out before
472
+ * matching — `_resolveSizeQueries`).
473
+ *
469
474
  * Unlike a state block, a size query *may* set layout properties. That is
470
475
  * not an inconsistency: pointer state changes must never reflow the tree,
471
476
  * but a size query is only ever re-evaluated during a layout pass that a
@@ -1206,16 +1211,37 @@ export function stripTokens(style) {
1206
1211
  * `strict` says the node's ancestry is complete, so a token that does not
1207
1212
  * resolve is a mistake. While a subtree is still being built its nodes can
1208
1213
  * see only part of their ancestry — the theme two levels up does not exist
1209
- * for them yet — so resolution there is provisional: unknown tokens are
1210
- * dropped and the node restyles when it attaches.
1214
+ * for them yet — so resolution there is provisional.
1215
+ *
1216
+ * Both cases drop the property, because a value is either resolved or absent
1217
+ * and `'$textMuted1'` is not a colour. What `strict` changes is whether
1218
+ * anyone hears about it: mistakes are pushed onto `problems` and the caller
1219
+ * decides what one costs. Resolving itself never throws — it runs from a
1220
+ * commit and from an X event alike, and only the caller knows whether React
1221
+ * is on the stack to route a throw to a boundary (src/nodes.js).
1222
+ *
1223
+ * A cache hit replays the problems it recorded, so the second node to wear a
1224
+ * misspelled shared style is reported like the first.
1211
1225
  */
1212
- export function resolveTokens(style, theme, where = 'style', strict = true) {
1226
+ export function resolveTokens(
1227
+ style,
1228
+ theme,
1229
+ where = 'style',
1230
+ strict = true,
1231
+ problems = null,
1232
+ ) {
1213
1233
  if (!theme) return stripTokens(style);
1214
1234
  let byTheme = strict ? resolvedCache.get(style) : null;
1215
1235
  if (strict && !byTheme) resolvedCache.set(style, (byTheme = new WeakMap()));
1216
1236
  const hit = byTheme?.get(theme);
1217
- if (hit) return hit;
1237
+ if (hit) {
1238
+ if (problems && hit.problems) problems.push(...hit.problems);
1239
+ return hit.out;
1240
+ }
1218
1241
 
1242
+ // collected here rather than pushed straight to `problems` so the cache
1243
+ // entry can keep them: the caller that misses is not the only one to hear
1244
+ const found = strict ? [] : null;
1219
1245
  const out = {};
1220
1246
  for (const key of Object.keys(style)) {
1221
1247
  const v = style[key];
@@ -1225,11 +1251,7 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
1225
1251
  out[key] = theme[name];
1226
1252
  continue;
1227
1253
  }
1228
- if (!strict) continue;
1229
- throw new Error(
1230
- `react-x11: unknown theme token "${v}" in ${where} ` +
1231
- `(theme has ${Object.keys(theme).join(', ') || 'nothing'})`,
1232
- );
1254
+ if (strict) found.push(unknownToken(v, theme, where));
1233
1255
  } else if (mentionsToken(v)) {
1234
1256
  let unknown = null;
1235
1257
  const substituted = v.replace(TOKEN_IN_VALUE, (token) => {
@@ -1245,13 +1267,10 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
1245
1267
  // frame instead of at the style.
1246
1268
  if (!unknown) out[key] = substituted;
1247
1269
  else if (strict) {
1248
- throw new Error(
1249
- `react-x11: unknown theme token "${unknown}" in ${where} ${key} ` +
1250
- `(theme has ${Object.keys(theme).join(', ') || 'nothing'})`,
1251
- );
1270
+ found.push(unknownToken(unknown, theme, `${where} ${key}`));
1252
1271
  }
1253
1272
  } else if (key.charCodeAt(0) === 58 && v) {
1254
- out[key] = resolveTokens(v, theme, `${where} ${key}`, strict);
1273
+ out[key] = resolveTokens(v, theme, `${where} ${key}`, strict, found);
1255
1274
  } else if (key === 'animation' && v && typeof v === 'object') {
1256
1275
  const loops = {};
1257
1276
  let incomplete = false;
@@ -1266,11 +1285,11 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
1266
1285
  theme,
1267
1286
  `${where} animation ${prop}`,
1268
1287
  strict,
1288
+ found,
1269
1289
  );
1270
- // A provisional resolution drops what it cannot resolve, which for
1271
- // an ordinary property means "not styled yet". A loop with one end
1272
- // missing is not a shorter loop, so the whole declaration waits for
1273
- // the ancestry to complete rather than throwing at a half of one.
1290
+ // A loop with one end missing is not a shorter loop, so a
1291
+ // declaration that lost a value is dropped whole rather than run
1292
+ // between a colour and nothing.
1274
1293
  if (Object.keys(resolved).length !== Object.keys(entry).length) {
1275
1294
  incomplete = true;
1276
1295
  }
@@ -1281,10 +1300,20 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
1281
1300
  out[key] = v;
1282
1301
  }
1283
1302
  }
1284
- byTheme?.set(theme, out);
1303
+ if (found?.length && problems) problems.push(...found);
1304
+ byTheme?.set(theme, { out, problems: found?.length ? found : null });
1285
1305
  return out;
1286
1306
  }
1287
1307
 
1308
+ /** The one message, written once: what was named, and what the palette in
1309
+ * force actually has — listing the alternatives is most of the fix. */
1310
+ function unknownToken(token, theme, where) {
1311
+ return (
1312
+ `react-x11: unknown theme token "${token}" in ${where} ` +
1313
+ `(theme has ${Object.keys(theme).join(', ') || 'nothing'})`
1314
+ );
1315
+ }
1316
+
1288
1317
  export { validateStyle };
1289
1318
 
1290
1319
  /**
@@ -23,6 +23,7 @@ import type {
23
23
  SubmitEvent,
24
24
  SyntheticEvent,
25
25
  ViewportEvent,
26
+ WheelEvent,
26
27
  WindowResizeEvent,
27
28
  } from './events.js';
28
29
 
@@ -841,6 +842,15 @@ export interface GlAreaProps extends DrawnProps<DrawnNode> {
841
842
  onDraw?: (gl: any, info: DrawInfo) => void;
842
843
  /** No GL surface — no GLX, or no matching visual. */
843
844
  onError?: (err: Error) => void;
845
+ /**
846
+ * The wheel over the surface. Inherited from `EventHandlers` like every
847
+ * other element's, and listed here because it is the **only** pointer
848
+ * event a `<glarea>` currently reports: the surface owns its own X window,
849
+ * so it selects the wheel there and hands it to the window's event manager
850
+ * (see docs/elements.md). Deltas are pixels, `preventDefault()` takes the
851
+ * default scroll action back, and it bubbles from this node.
852
+ */
853
+ onWheel?: (ev: WheelEvent<DrawnNode>) => void;
844
854
  /** A click inside the surface that hit no mesh. */
845
855
  onPointerMissed?: (ev: MouseEvent<DrawnNode>) => void;
846
856
  }