react-x11 2.49.0 → 2.50.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.
@@ -37,6 +37,8 @@
37
37
  import { cssColorStraight } from 'ntk/color';
38
38
  import { shadowReach, shadowSigma } from 'ntk/shadow-math';
39
39
 
40
+ import { isCurrentColor } from './currentcolor.js';
41
+
40
42
  /** A number, with or without the `px` CSS wants and this vocabulary does not. */
41
43
  const LENGTH = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:px)?$/i;
42
44
  /** …and the same thing as a percentage, which colour stops also take. */
@@ -50,9 +52,11 @@ const HORIZONTAL = new Set(['left', 'right']);
50
52
  /**
51
53
  * Split on `,` or on whitespace, but only at the top level: a stop is
52
54
  * `rgba(0, 0, 0, .4)` as often as it is `#333`, and every naive split on
53
- * this vocabulary gets that wrong.
55
+ * this vocabulary gets that wrong. The shorthands split by it too
56
+ * (src/shorthands.js) — `border: '1px solid rgba(0, 0, 0, .1)'` is three
57
+ * values, not six.
54
58
  */
55
- function splitTop(text, byComma) {
59
+ export function splitTop(text, byComma) {
56
60
  const out = [];
57
61
  let depth = 0;
58
62
  let start = 0;
@@ -207,14 +211,16 @@ function readStop(part, value) {
207
211
  return { color: stopColor(part, value), position: null };
208
212
  }
209
213
 
210
- /** A stop's colour, which RENDER is handed as written — and ntk throws on
211
- * one it cannot parse from inside the frame, where nothing can catch it. */
214
+ /** A stop's colour, which RENDER is handed as written once a `currentColor`
215
+ * is resolved (`_backgroundGradient`) — and ntk throws on one it cannot
216
+ * parse from inside the frame, where nothing can catch it. */
212
217
  function stopColor(color, value) {
213
218
  if (isColorOrToken(color)) return color;
214
219
  throw new Error(
215
220
  `react-x11: backgroundImage ${JSON.stringify(value)} has a colour stop ` +
216
221
  `that is not a colour ("${color}"; expected one like '#2980b9', ` +
217
- "'tomato' or 'rgba(0, 0, 0, .4)', or a $token, before its position)",
222
+ "'tomato', 'rgba(0, 0, 0, .4)' or 'currentColor', or a $token, before " +
223
+ 'its position)',
218
224
  );
219
225
  }
220
226
 
@@ -223,10 +229,15 @@ function stopColor(color, value) {
223
229
  * One that mentions a `$token` is let through where the style is written,
224
230
  * since the token means nothing yet; the paint parses the value it resolves
225
231
  * to, and a colour that is still not one drops the whole decoration there
226
- * rather than throwing (see "memoized parsing" below).
232
+ * rather than throwing (see "memoized parsing" below). `currentColor` is
233
+ * the node's ink, which only the paint knows (`Node.resolveColor`).
227
234
  */
228
235
  function isColorOrToken(color) {
229
- return color.includes('$') || cssColorStraight(color) !== null;
236
+ return (
237
+ color.includes('$') ||
238
+ cssColorStraight(color) !== null ||
239
+ isCurrentColor(color)
240
+ );
230
241
  }
231
242
 
232
243
  /**
@@ -349,7 +360,8 @@ function resolveStopOffsets(stops, length) {
349
360
  * renderer draws the list back to front.
350
361
  *
351
362
  * `color` may be null, which is CSS's `currentColor` — the node's own ink,
352
- * which the caller resolves because only it knows the cascade.
363
+ * which the caller resolves because only it knows the cascade. The keyword
364
+ * written out reads as null too, so a shadow has one way to say it.
353
365
  *
354
366
  * Throws on `inset` and on anything unparseable; see the note at the top of
355
367
  * this file for why a shadow that silently does the wrong thing is worse
@@ -394,7 +406,7 @@ function readShadow(part, value) {
394
406
  }
395
407
  const [dx, dy, blur = 0, spread = 0] = lengths;
396
408
  if (blur < 0) throw bad('has a negative blur radius');
397
- return { dx, dy, blur, spread, color };
409
+ return { dx, dy, blur, spread, color: isCurrentColor(color) ? null : color };
398
410
  }
399
411
 
400
412
  /**
package/src/editor.js ADDED
@@ -0,0 +1,151 @@
1
+ // Source locations and the editor that opens them: reading a stack trace
2
+ // into frames, telling the frames of an app's own code from React's and this
3
+ // renderer's, and handing a file and line to the developer's editor. Shared by
4
+ // click-to-component (an Alt+Click on an element) and `react-x11 dev`'s error
5
+ // window (a click on a frame), so the two open the same editor the same way.
6
+ // A leaf: nothing here reaches the renderer or React DevTools.
7
+ import { spawn } from 'node:child_process';
8
+ import { dirname, sep } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ export const STACK_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?\s*$/;
12
+
13
+ // The frames to walk past before the JSX call site itself: React captures
14
+ // the Error inside `jsxDEV`/`createElement`, and an element created by this
15
+ // renderer (or by node internals) is never what a click means. `SELF_DIR` is
16
+ // whichever copy of react-x11 is running — the repo's own `src/` for the
17
+ // examples and tests, an installed `node_modules/react-x11/src` for an app.
18
+ //
19
+ // Everything *else* in `node_modules` is deliberately not skipped here: a
20
+ // frame inside an installed package is a real call site, it is just not one
21
+ // in the application's source, and telling the two apart is what lets
22
+ // `resolveOwnedLocation` climb to the owner that is. Skipping every
23
+ // `node_modules` frame instead would walk straight past the library
24
+ // component and land on whatever application frame happens to be deeper in
25
+ // the render stack — usually the `render()` call at startup, which looks
26
+ // like an answer and isn't.
27
+ const REACT_RUNTIME =
28
+ /[\\/]node_modules[\\/](react|react-dom|react-reconciler|react-refresh|scheduler)[\\/]/;
29
+ const SELF_DIR = dirname(fileURLToPath(import.meta.url)) + sep;
30
+
31
+ export function isInternalFrame(file) {
32
+ return (
33
+ file.startsWith('node:') ||
34
+ file === '<anonymous>' ||
35
+ file.startsWith(SELF_DIR) ||
36
+ REACT_RUNTIME.test(file)
37
+ );
38
+ }
39
+
40
+ // GUI editors: navigate their registered URI scheme (same idea as
41
+ // https://github.com/sidorares/show-component, minus the browser) instead of
42
+ // spawning their CLI shim. The CLI wrapper (e.g. `cursor -g ...`) round-trips
43
+ // through a shell script that re-detects and re-execs the real app, which is
44
+ // visibly slower than the OS handing the URL straight to the already-running
45
+ // instance via Launch Services (macOS) / xdg-open (Linux).
46
+ const EDITOR_SCHEMES = {
47
+ cursor: 'cursor',
48
+ code: 'vscode',
49
+ vscode: 'vscode',
50
+ 'code-insiders': 'vscode-insiders',
51
+ 'vscode-insiders': 'vscode-insiders',
52
+ windsurf: 'windsurf',
53
+ };
54
+
55
+ // Terminal editors have no URI scheme to navigate — spawn them directly.
56
+ const EDITOR_CLI = {
57
+ vim: (file, line, column) => [
58
+ 'vim',
59
+ [`+call cursor(${line},${column})`, file],
60
+ ],
61
+ nvim: (file, line, column) => [
62
+ 'nvim',
63
+ [`+call cursor(${line},${column})`, file],
64
+ ],
65
+ };
66
+
67
+ const OPEN_URI_COMMAND = process.platform === 'darwin' ? 'open' : 'xdg-open';
68
+
69
+ function editorUri(scheme, file, line, column) {
70
+ return `${scheme}://file${encodeURI(file)}:${line}:${column}`;
71
+ }
72
+
73
+ export function openInEditor({ file, line, column }) {
74
+ const name = editorName();
75
+ let bin, args;
76
+ if (EDITOR_CLI[name]) {
77
+ [bin, args] = EDITOR_CLI[name](file, line, column);
78
+ } else {
79
+ // an unrecognized name is assumed to be a URI scheme itself, so any
80
+ // editor that registers one works without a matching entry above
81
+ const scheme = EDITOR_SCHEMES[name] || name;
82
+ bin = OPEN_URI_COMMAND;
83
+ args = [editorUri(scheme, file, line, column)];
84
+ }
85
+ // stderr is kept (rather than the whole stdio ignored) for one reason:
86
+ // `open`/`xdg-open` exit non-zero and print *there* when nothing claims
87
+ // the scheme — an editor that isn't installed, or one whose URL handler
88
+ // was never registered. Swallowing that is what makes click-to-component
89
+ // look like it only logs: the location resolves, the console line prints,
90
+ // and the editor silently never opens.
91
+ const child = spawn(bin, args, {
92
+ detached: true,
93
+ stdio: ['ignore', 'ignore', 'pipe'],
94
+ });
95
+ let stderr = '';
96
+ // ...and unref'd, so keeping it does not keep the process alive: a pipe is
97
+ // a ref'd handle, and an editor that outlives the app it was launched from
98
+ // is the normal case, not a reason to hold the event loop open.
99
+ child.stderr.unref();
100
+ child.stderr.on('data', (chunk) => {
101
+ stderr += chunk;
102
+ });
103
+ child.on('error', (err) => {
104
+ console.warn(
105
+ `react-x11: could not launch "${bin}" to open ${file}:${line} (${err.code ?? err.message}). ` +
106
+ 'Set REACT_X11_EDITOR to your editor CLI (cursor, code, code-insiders, windsurf, vim, nvim).',
107
+ );
108
+ });
109
+ child.on('exit', (code) => {
110
+ if (!code) return;
111
+ console.warn(
112
+ `react-x11: opening ${file}:${line} — \`${bin} ${args.join(' ')}\` exited ${code}. ` +
113
+ (stderr.trim() ? `${stderr.trim()} ` : '') +
114
+ `Set REACT_X11_EDITOR to the editor you actually use (currently "${name}"; ` +
115
+ 'cursor, code, code-insiders, windsurf, vim, nvim, or any registered URI scheme).',
116
+ );
117
+ });
118
+ child.unref();
119
+ }
120
+
121
+ /** The editor `openInEditor` opens: `REACT_X11_EDITOR`, else `cursor`. */
122
+ export function editorName() {
123
+ return process.env.REACT_X11_EDITOR || 'cursor';
124
+ }
125
+
126
+ /**
127
+ * A stack trace's frames, in order: where each one is, what it is called, and
128
+ * whether it is React's, Node's or this renderer's own (`internal`) or inside
129
+ * an installed package (`installed`). Lines that are not frames — the message,
130
+ * a `[cause]` — are not in the list.
131
+ */
132
+ export function stackFrames(stack) {
133
+ const frames = [];
134
+ for (const text of String(stack ?? '').split('\n')) {
135
+ const match = text.match(STACK_FRAME);
136
+ if (!match) continue;
137
+ const [, functionName, rawFile, lineStr, columnStr] = match;
138
+ const file = rawFile.startsWith('file://')
139
+ ? fileURLToPath(rawFile)
140
+ : rawFile;
141
+ frames.push({
142
+ functionName: functionName ?? null,
143
+ file,
144
+ line: Number(lineStr),
145
+ column: Number(columnStr),
146
+ internal: isInternalFrame(file),
147
+ installed: file.includes(`${sep}node_modules${sep}`),
148
+ });
149
+ }
150
+ return frames;
151
+ }
@@ -482,12 +482,19 @@ export class ForeignNode extends Node {
482
482
  _applyBackground() {
483
483
  const wnd = this.socket?.window;
484
484
  if (typeof wnd?.setBackgroundPixel !== 'function') return;
485
- const pixel = pixelFor(this.style.backgroundColor);
485
+ const pixel = pixelFor(this.resolveColor(this.style.backgroundColor));
486
486
  if (pixel === null || pixel === this._backgroundPixel) return;
487
487
  this._backgroundPixel = pixel;
488
488
  wnd.setBackgroundPixel(pixel);
489
489
  }
490
490
 
491
+ /** …which a `currentColor` takes from the ink, and the ink can move with
492
+ * no commit here. */
493
+ _inkMoved() {
494
+ super._inkMoved();
495
+ this._applyBackground();
496
+ }
497
+
491
498
  /** The client's window is a window of the display system over this one,
492
499
  * so it follows the whole subtree's visibility — see
493
500
  * `GlAreaNode._syncSurfaceVisibility`, which answers the same call. */
package/src/gloverlay.js CHANGED
@@ -86,6 +86,7 @@
86
86
  import { cssColorStraight } from 'ntk/color';
87
87
 
88
88
  import { FULL_DAMAGE, addDamageRect } from './nodes/damage.js';
89
+ import { radiusOf } from './styles.js';
89
90
  import {
90
91
  innerPixels,
91
92
  intersectRects,
@@ -1200,7 +1201,7 @@ export class GlOverlay {
1200
1201
  }
1201
1202
  const area = this.area;
1202
1203
  // a rounded surface clips its children to an arc, and the arc stays put
1203
- if ((area.style?.borderRadius ?? 0) > 0) return false;
1204
+ if (radiusOf(area.style, area.abs) > 0) return false;
1204
1205
  // the whole pixels of the surface's box: a fractional edge is an
1205
1206
  // antialiased clip, and that does not move either
1206
1207
  const box = innerPixels(area.abs);
package/src/node.d.ts CHANGED
@@ -624,6 +624,11 @@ export declare class Node {
624
624
  * with none of its own is set in. An element that draws text inherits
625
625
  * exactly what `<text>` inherits by asking. */
626
626
  resolvedTextStyle(): TextStyle;
627
+ /** A colour from this node's style as one a context can paint: the value
628
+ * itself, or — for `currentColor` — the node's ink, the colour
629
+ * `resolvedTextStyle()` resolves. Everything core paints from a style
630
+ * colour asks here; an element painting one of its own should too. */
631
+ resolveColor<T>(value: T): T | string;
627
632
  /** The flattened `style` prop with the active state blocks overlaid.
628
633
  * Everything that paints or lays out reads this, never `props`. */
629
634
  readonly style: Style;
@@ -11,9 +11,11 @@ import {
11
11
  transitionFor,
12
12
  interpolate,
13
13
  ease,
14
+ isColor,
14
15
  isLayoutProp,
15
16
  TEXT_LAYOUT_PROPS,
16
17
  } from '../styles.js';
18
+ import { isCurrentColor } from '../currentcolor.js';
17
19
  import { GRID_CONTAINER_PROPS, GRID_ITEM_PROPS } from '../grid.js';
18
20
  import { isPlaced } from '../layouts.js';
19
21
  import { desktopSettings, watchDesktopSettings } from '../desktopsettings.js';
@@ -85,11 +87,16 @@ export class NodeAnimation {
85
87
  // transitions start on later changes, which is CSS's rule too.
86
88
  if (this._placed) {
87
89
  for (const prop of Object.keys(target)) {
88
- const to = target[prop];
89
- const from = displayed[prop];
90
+ let to = target[prop];
91
+ let from = displayed[prop];
90
92
  if (from === to || from === undefined) continue;
91
93
  const duration = transitionFor(target, prop);
92
94
  if (duration <= 0) continue;
95
+ if (isCurrentColor(from) || isCurrentColor(to)) {
96
+ from = this._swapInk(prop, from, displayed);
97
+ to = this._swapInk(prop, to, target);
98
+ if (from === to) continue;
99
+ }
93
100
  if (interpolate(from, to, 0.5) === null) continue; // no midpoint: snap
94
101
  const entry = {
95
102
  from,
@@ -260,6 +267,27 @@ export class NodeAnimation {
260
267
  return this.style;
261
268
  }
262
269
 
270
+ /**
271
+ * A `currentColor` end of a transition as the colour it is at the swap:
272
+ * the ink `style` gives this node, or on `color` itself the ink it
273
+ * inherits. The keyword has no midpoint of its own, so the transition
274
+ * runs between the two colours as they are now — and lands on the style,
275
+ * where the keyword follows the ink again. An ink that moves mid-flight
276
+ * is caught up with at the end, not chased: a `color` that should carry
277
+ * a border with it is the property to transition.
278
+ */
279
+ _swapInk(prop, value, style) {
280
+ if (!isCurrentColor(value)) return value;
281
+ const inherited = this.inheritedTextStyle.color;
282
+ if (prop === 'color') return inherited;
283
+ // the displayed style's ink is cached; the target's is not yet
284
+ return style === this.style
285
+ ? this.resolvedTextStyle().color
286
+ : isColor(style.color)
287
+ ? style.color
288
+ : inherited;
289
+ }
290
+
263
291
  _animatedValues() {
264
292
  const values = {};
265
293
  for (const [prop, a] of this._anim) {
@@ -388,7 +416,7 @@ export class NodeAnimation {
388
416
  const specs =
389
417
  target.animation == null
390
418
  ? null
391
- : animationsOf(target, 'a style', this.scale);
419
+ : animationsOf(target, 'a style', this.scale, this._units);
392
420
  if (!specs && !this._loops) return false;
393
421
  this._loops = specs;
394
422
  if (!specs) this.root?._forgetLoopNode(this);
@@ -5,9 +5,11 @@
5
5
  import {
6
6
  DEFAULT_FOCUS_RING,
7
7
  isColor,
8
+ radiusOf,
8
9
  resolveBorderWidths,
9
10
  resolveBorderColors,
10
11
  } from '../styles.js';
12
+ import { isCurrentColor } from '../currentcolor.js';
11
13
  import {
12
14
  blurKernel,
13
15
  gradientSpec,
@@ -61,6 +63,19 @@ let warnedSideRadius = false;
61
63
 
62
64
  /** Box decorations, installed onto `Node.prototype` by node.js. */
63
65
  export class NodeBoxPaint {
66
+ /**
67
+ * A colour from this node's style as a colour a context can paint: the
68
+ * value itself, or — for CSS's `currentColor` — this node's resolved ink,
69
+ * its own `color` over what it inherits. Everything that paints a style
70
+ * colour asks here, on every backend, so the keyword means one thing
71
+ * wherever it is written; a registered element painting a colour of its
72
+ * own style asks the same. A node whose style paints in the keyword
73
+ * repaints when its ink moves (`_inkMoved`).
74
+ */
75
+ resolveColor(value) {
76
+ return isCurrentColor(value) ? this.resolvedTextStyle().color : value;
77
+ }
78
+
64
79
  /**
65
80
  * The focus ring this node would draw: its own `outline*` style, falling
66
81
  * back to the theme's focus-ring tokens for anything it leaves out. Null
@@ -80,7 +95,9 @@ export class NodeBoxPaint {
80
95
  if (!(resolved > 0)) return null;
81
96
  return {
82
97
  width: resolved,
83
- color: style.outlineColor ?? theme?.focusRing ?? DEFAULT_FOCUS_RING.color,
98
+ color: this.resolveColor(
99
+ style.outlineColor ?? theme?.focusRing ?? DEFAULT_FOCUS_RING.color,
100
+ ),
84
101
  offset:
85
102
  style.outlineOffset ??
86
103
  theme?.focusRingOffset ??
@@ -138,7 +155,7 @@ export class NodeBoxPaint {
138
155
  // stroked centred on the path, like the border, so half the width sits
139
156
  // inside the offset gap and half outside it
140
157
  const grow = offset + width / 2;
141
- const radius = this.style.borderRadius ?? 0;
158
+ const radius = radiusOf(this.style, this.abs);
142
159
  ctx.strokeStyle = outline.color;
143
160
  ctx.lineWidth = width;
144
161
  ctx.beginPath();
@@ -162,7 +179,8 @@ export class NodeBoxPaint {
162
179
  // under an element that covers this pass with opaque pixels, this fill
163
180
  // is never seen (`WindowNode._coverFor`)
164
181
  if (this.root?._coverChain?.has(this)) return;
165
- const { backgroundColor, borderRadius = 0 } = this.style;
182
+ const backgroundColor = this.resolveColor(this.style.backgroundColor);
183
+ const borderRadius = radiusOf(this.style, this.abs);
166
184
  const fill = (style) => {
167
185
  ctx.fillStyle = style;
168
186
  if (borderRadius > 0) {
@@ -202,7 +220,11 @@ export class NodeBoxPaint {
202
220
  const spec = gradientSpec(value, this.scale);
203
221
  if (!spec) return null;
204
222
  const { x, y, width, height } = rect;
205
- const key = `${value}|${x},${y},${width},${height}`;
223
+ // a stop in `currentColor` is the ink, which can move under a value
224
+ // that did not, so the ink is part of what the gradient is keyed by
225
+ const inked = spec.stops.some((stop) => isCurrentColor(stop.color));
226
+ const ink = inked ? this.resolvedTextStyle().color : '';
227
+ const key = `${value}|${x},${y},${width},${height}|${ink}`;
206
228
  if (this._gradient?.key === key) return this._gradient.value;
207
229
  const line = linearGradientGeometry(spec, rect);
208
230
  if (!line) return null;
@@ -213,7 +235,7 @@ export class NodeBoxPaint {
213
235
  line.y1,
214
236
  );
215
237
  for (const [offset, color] of line.stops)
216
- gradient.addColorStop(offset, color);
238
+ gradient.addColorStop(offset, inked ? this.resolveColor(color) : color);
217
239
  this._gradient = { key, value: gradient };
218
240
  return gradient;
219
241
  }
@@ -235,7 +257,7 @@ export class NodeBoxPaint {
235
257
  if (!value || value === 'none') return;
236
258
  const shadows = shadowSpecs(value, this.scale);
237
259
  if (!shadows?.length) return;
238
- const radius = this.style.borderRadius ?? 0;
260
+ const radius = radiusOf(this.style, this.abs);
239
261
  for (let i = shadows.length - 1; i >= 0; i--) {
240
262
  const shadow = shadows[i];
241
263
  // CSS's `currentColor`: this node's *resolved* ink, its own `color`
@@ -404,11 +426,15 @@ export class NodeBoxPaint {
404
426
  }
405
427
 
406
428
  _paintBorder(ctx) {
407
- const { borderRadius = 0 } = this.style;
429
+ const borderRadius = radiusOf(this.style, this.abs);
408
430
  // Both through the node's resolved direction: a `borderStartWidth` lays
409
431
  // out on one side and has to paint on the same one.
410
432
  const w = resolveBorderWidths(this.style, this.direction);
411
433
  const colors = resolveBorderColors(this.style, this.direction);
434
+ colors.top = this.resolveColor(colors.top);
435
+ colors.right = this.resolveColor(colors.right);
436
+ colors.bottom = this.resolveColor(colors.bottom);
437
+ colors.left = this.resolveColor(colors.left);
412
438
  const uniform =
413
439
  w.top === w.right &&
414
440
  w.top === w.bottom &&
@@ -469,7 +495,7 @@ export class NodeBoxPaint {
469
495
  * strokes halfway.
470
496
  */
471
497
  _paintBorderSides(ctx, w, colors) {
472
- if (DEV && (this.style.borderRadius ?? 0) > 0 && !warnedSideRadius) {
498
+ if (DEV && radiusOf(this.style, this.abs) > 0 && !warnedSideRadius) {
473
499
  warnedSideRadius = true;
474
500
  console.warn(
475
501
  'react-x11: borderRadius needs uniform borders — same width and ' +
@@ -5,12 +5,14 @@
5
5
  import {
6
6
  applyLayoutStyle,
7
7
  isColor,
8
+ paintsCurrentColor,
8
9
  textStyleFrom,
9
10
  DEFAULT_TEXT_STYLE,
10
11
  localTextStyleChanged,
11
12
  resolvedTextDelta,
12
13
  TEXT_REMEASURE,
13
14
  } from '../styles.js';
15
+ import { UNIT_EM, UNIT_REM } from '../shorthands.js';
14
16
  import { Yoga } from '../yoga.js';
15
17
  import { scaleOf } from '../scale.js';
16
18
  import { baseTheme } from '../palette.js';
@@ -372,8 +374,22 @@ export class NodeCascade {
372
374
  const before = this._resolvedText;
373
375
  if (before === undefined) return 0;
374
376
  this._resolvedText = undefined;
375
- const cost = resolvedTextDelta(before, this.resolvedTextStyle());
376
- if (cost !== 0) this._textStyleMoved(cost);
377
+ // A length in `em` is a length in the font size this node inherits,
378
+ // which is what this walk exists because of — so a node that wrote one
379
+ // resolves its style again here, before its own text is, since a
380
+ // `fontSize: '1.5em'` is part of what that text is set in.
381
+ if (this._emMoved()) this._unitsMoved();
382
+ const after = this.resolvedTextStyle();
383
+ const cost = resolvedTextDelta(before, after);
384
+ if (cost !== 0) {
385
+ this._textStyleMoved(cost);
386
+ // A node painting in `currentColor` resolved its ink to do it, so the
387
+ // walk reaches it — and its own style says nothing about the change,
388
+ // which was written on an ancestor or in its own `color`.
389
+ if (before.color !== after.color && paintsCurrentColor(this.style)) {
390
+ this._inkMoved();
391
+ }
392
+ }
377
393
  return cost;
378
394
  }
379
395
 
@@ -405,6 +421,19 @@ export class NodeCascade {
405
421
  }
406
422
  }
407
423
 
424
+ /**
425
+ * The ink moved under a node that paints something in it — a border, a
426
+ * fill, a gradient stop or a shadow in `currentColor`. A repaint of the
427
+ * node, bounded to it, and a box needs it although it draws no text
428
+ * (`BoxNode._textStyleMoved`): the ancestor whose `color` moved claimed
429
+ * its own box, which covers this one on a surface but names no node to a
430
+ * retained presenter re-rastering by node, and no other window to a
431
+ * `<popup>` written under it.
432
+ */
433
+ _inkMoved() {
434
+ this.root?.invalidate(false, this, 'ink');
435
+ }
436
+
408
437
  /**
409
438
  * Whether this node's ancestry is complete, so a `$token` that does not
410
439
  * resolve is a mistake rather than a node that has not been placed yet.
@@ -508,7 +537,9 @@ export class NodeCascade {
508
537
  // and the server's copy of that colour has to follow with it — otherwise
509
538
  // the next resize fills the new area in the old scheme.
510
539
  if (this.isWindow) this._syncWindowBackground();
511
- if (this._usesTokens) {
540
+ // A relative length reads the palette too: `rem` is its `fontSize`,
541
+ // and `em` is what that size became by the time it reached this node.
542
+ if (this._usesTokens || this._unitDeps & (UNIT_REM | UNIT_EM)) {
512
543
  const before = this.style;
513
544
  this._syncStyle(this.props, mounting);
514
545
  // a token change reaches the node without React re-rendering it, so
@@ -516,6 +547,15 @@ export class NodeCascade {
516
547
  if (localTextStyleChanged(this.style, before)) {
517
548
  this._textContentChanged();
518
549
  }
550
+ // …and so does the layout half. A token in a layout value —
551
+ // `padding: '$gutter'` — resolves right here, on attach or on a
552
+ // palette swap, and yoga was last handed the style the constructor
553
+ // had, with its tokens stripped. Same tail as `_sizeQueriesChanged`
554
+ // and `_rescaled`; without it a themed gutter reached `this.style`
555
+ // and the paint, and the layout kept the un-themed value.
556
+ if (this.yoga && this.style !== before) {
557
+ applyLayoutStyle(this.yoga, this.style, before);
558
+ }
519
559
  if (!mounting) this.root?.invalidate(true, null, 'theme');
520
560
  }
521
561
  // The palette is the floor under the cascade, so a theme swap moves the
@@ -624,4 +664,11 @@ export class WindowCascade {
624
664
  /** Like a `<box>`: a window fills and never letters, so it is the top of
625
665
  * the cascade rather than a reader of it. */
626
666
  _textStyleMoved() {}
667
+
668
+ /** …except a fill in `currentColor`, which the server keeps a copy of for
669
+ * what a resize exposes. */
670
+ _inkMoved() {
671
+ this._syncWindowBackground();
672
+ this.invalidate(false, null, 'ink');
673
+ }
627
674
  }
@@ -86,8 +86,10 @@ export const INVALIDATE_REASONS = new Set([
86
86
  'shadow', // a boxShadow got smaller: where it *was* still owes a repaint
87
87
  'outline', // …and the same for an outline a style swap took away
88
88
  'theme', // a theme/token change restyled a subtree
89
+ 'ink', // the ink under a currentColor moved: its `color` or an ancestor's
89
90
  'direction', // the reading direction moved: sides, glyph order, bar edge
90
91
  'scale', // a `scale` prop zoomed a subtree: every length in it moved
92
+ 'units', // a font size an `em` length read moved, and the style with it
91
93
  'animation', // a transition frame
92
94
  'scroll', // scrollTo/scrollBy/scrollIntoView, textarea/textinput panning
93
95
  'text', // text content, input value or caret editing
@@ -5,7 +5,7 @@
5
5
  import { Yoga } from '../yoga.js';
6
6
  import { callHandler } from '../errors.js';
7
7
  import { DAMAGE_SLOP, layoutDiff } from './damage.js';
8
- import { resolveBorderWidths } from '../styles.js';
8
+ import { radiusOf, resolveBorderWidths } from '../styles.js';
9
9
  import { insetRect, intersectRects, outerPixels, outside } from './rects.js';
10
10
  import { DEV } from './util.js';
11
11
 
@@ -612,25 +612,17 @@ export class NodeLayout {
612
612
  // (`roundedCorners`), so a resize that moves that moves all four
613
613
  // corners — and their outline and shadow with them — and the ones at the
614
614
  // edges that held are in no band along the edges that moved.
615
- const radius = style.borderRadius ?? 0;
615
+ // A percentage radius moves with the box for the same reason: it is a
616
+ // share of the shorter side (`radiusOf`), read off each rect.
616
617
  const now = this.abs;
617
- if (
618
- radius > 0 &&
619
- Math.min(radius, old.width / 2, old.height / 2) !==
620
- Math.min(radius, now.width / 2, now.height / 2)
621
- ) {
622
- return null;
623
- }
618
+ const corner = (rect) =>
619
+ Math.min(radiusOf(style, rect), rect.width / 2, rect.height / 2);
620
+ const radius = Math.max(radiusOf(style, old), radiusOf(style, now));
621
+ if (radius > 0 && corner(old) !== corner(now)) return null;
624
622
  const bw = resolveBorderWidths(style, this.direction);
625
623
  const reach =
626
- Math.max(
627
- style.borderRadius ?? 0,
628
- bw.top,
629
- bw.right,
630
- bw.bottom,
631
- bw.left,
632
- grow,
633
- ) + DAMAGE_SLOP;
624
+ Math.max(radius, bw.top, bw.right, bw.bottom, bw.left, grow) +
625
+ DAMAGE_SLOP;
634
626
  const bands = [];
635
627
  const oldBottom = old.y + old.height;
636
628
  const newBottom = now.y + now.height;
@@ -16,7 +16,7 @@
16
16
  // mistake it can make is not firing.
17
17
 
18
18
  import { cssColorStraight } from 'ntk/color';
19
- import { EMPTY_STYLE, resolveBorderWidths } from '../styles.js';
19
+ import { EMPTY_STYLE, radiusOf, resolveBorderWidths } from '../styles.js';
20
20
  import { isPaintedColor } from './boxpaint.js';
21
21
  import { DAMAGE_SLOP, FULL_DAMAGE, addDamageRect } from './damage.js';
22
22
  import { NodePaint } from './paint.js';
@@ -100,7 +100,7 @@ function cornerSquares(box, radius) {
100
100
  * Null when nothing is left.
101
101
  */
102
102
  function clipBoxOf(node) {
103
- const radius = node.style?.borderRadius ?? 0;
103
+ const radius = radiusOf(node.style, node.abs);
104
104
  return innerPixels(radius > 0 ? insetRect(node.abs, radius) : node.abs);
105
105
  }
106
106
 
@@ -166,9 +166,9 @@ export class NodeMoveBlit {
166
166
  const style = node.style ?? EMPTY_STYLE;
167
167
  if (style.display === 'none') return null;
168
168
  if (style.opacity !== undefined && !(style.opacity >= 1)) return null;
169
- const radius = style.borderRadius ?? 0;
169
+ const radius = radiusOf(style, node.abs);
170
170
  let own = null;
171
- if (isOpaqueColor(style.backgroundColor)) {
171
+ if (isOpaqueColor(node.resolveColor(style.backgroundColor))) {
172
172
  own = node.abs;
173
173
  holes.push(...cornerSquares(node.abs, radius));
174
174
  } else {
@@ -581,7 +581,7 @@ export class WindowMoveBlit {
581
581
  if (ring > 0) {
582
582
  const inner = insetRect(
583
583
  n.abs,
584
- Math.max(ring, style.borderRadius ?? 0) + DAMAGE_SLOP,
584
+ Math.max(ring, radiusOf(style, n.abs)) + DAMAGE_SLOP,
585
585
  );
586
586
  const outer = insetRect(n.abs, -DAMAGE_SLOP);
587
587
  for (const piece of outside(outer, inner)) over(piece);