react-x11 2.48.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.
@@ -22,6 +22,8 @@
22
22
  // name: it runs before the entry is even loaded, and skips the checks.
23
23
  import fs from 'node:fs';
24
24
  import { createRequire } from 'node:module';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
25
27
  import { isMainThread, SHARE_ENV, Worker } from 'node:worker_threads';
26
28
 
27
29
  import { loadNative } from './native.js';
@@ -156,6 +158,15 @@ const ENTRY_POINT = new Set([
156
158
  // ts-node/esm`, `--experimental-loader ./hooks.mjs`.
157
159
  const LOADER_FLAG = /^--(?:import|loader|experimental-loader)(?:=|$)/;
158
160
 
161
+ // The module Bun runs an entry from (`startedRunning`): one it writes
162
+ // itself, which imports the entry (measured, Bun 1.4).
163
+ const BUN_MAIN = 'bun:main';
164
+
165
+ // What Bun parses an entry as, by its extension: a .ts file may hold a
166
+ // `<T>x` cast, which TSX reads as an element, and JSX is allowed in a .js
167
+ // file, where the JSX reader takes any script a plain one would.
168
+ const SCAN_LOADER = { '.tsx': 'tsx', '.ts': 'ts', '.mts': 'ts', '.cts': 'ts' };
169
+
159
170
  /**
160
171
  * Why this import should not move the app onto a worker, or null when it
161
172
  * should. A pure answer over what the process says about itself, so that
@@ -278,14 +289,14 @@ function compiledExecutable(bun) {
278
289
  * and later evaluate an `import()` the entry makes before the loop's first
279
290
  * turn, so an app that made one moved and ran its entry twice.
280
291
  *
281
- * Bun reports 1 there from the first line; what tells in Bun is the entry's
282
- * record in the module cache, which is there once the entry has been
283
- * evaluated and absent while its imports are (measured, Bun 1.4).
292
+ * Bun reports no loop there, its `nodeTiming` a stand-in at 1 once
293
+ * `node:perf_hooks` is loaded, and leaves no frame below a module it
294
+ * evaluates, so it is read its own way (`bunStartedRunning`).
284
295
  */
285
296
  function startedRunning(bun, moduleUrl, entry) {
286
297
  if (!bun) return !evaluatedFromEntryPoint();
287
298
  try {
288
- return Boolean(createRequire(moduleUrl).cache?.[entry]?.loaded);
299
+ return bunStartedRunning(bun, moduleUrl, entry);
289
300
  } catch {
290
301
  return false;
291
302
  }
@@ -293,13 +304,106 @@ function startedRunning(bun, moduleUrl, entry) {
293
304
 
294
305
  /** Whether this stack, async frames and all, comes from `ENTRY_POINT`. */
295
306
  function evaluatedFromEntryPoint() {
307
+ const sites = callSites();
308
+ if (!sites) return false;
309
+ for (const site of sites) {
310
+ const file = site.getFileName() ?? '';
311
+ if (ENTRY_POINT.has(file)) return true;
312
+ // the app's own code awaits this import, so it has run already
313
+ if (site.isAsync() && !file.startsWith('node:')) return false;
314
+ }
315
+ return false;
316
+ }
317
+
318
+ /**
319
+ * `startedRunning` in Bun, read from three things it shows of the entry's
320
+ * run (each measured, Bun 1.4).
321
+ *
322
+ * Its module registry. Bun runs the entry from a module it writes itself,
323
+ * `bun:main`, which imports it, and `require.cache` lists an ES module once
324
+ * it has been evaluated: `bun:main` is there once the entry has finished,
325
+ * top-level awaits and all, and an import made after that is late. Before
326
+ * then the entry's own record says nothing either way. Bun marks a CommonJS
327
+ * module `loaded` before its body runs, which read as late from a CommonJS
328
+ * entry's first line, so it never moved; and an ES module has no record
329
+ * until it is evaluated, which read as not started for an entry awaiting
330
+ * its `import()` of react-x11, so it moved and ran a second time on the
331
+ * worker.
332
+ *
333
+ * The stack. Bun's loader evaluates an ES module's imports with no frame
334
+ * below the module, so a frame below react-x11 is code that loaded it
335
+ * synchronously. A CommonJS entry is in `require.cache` from its first line,
336
+ * and on its first run its own wrapper is the outermost frame. Bun runs the
337
+ * nextTicks and microtasks that first run queues before it evaluates
338
+ * `bun:main`: a callback there in another module reads as late, and one in
339
+ * the entry itself as its first run, unless in strict code it ends by
340
+ * returning its `require()`, which JavaScriptCore makes a tail call that
341
+ * leaves it no frame. For an ES module entry, a frame below react-x11 is a
342
+ * CommonJS module the entry imports, on its first run, or a `require()` in
343
+ * a module's body, and a bundle carries react-x11 inside the entry itself:
344
+ * each of them the start.
345
+ *
346
+ * The entry's static imports. An ES module's body runs once every module it
347
+ * imports statically has been evaluated: while one has not, the entry has
348
+ * not run a line and react-x11 is part of its graph, an `import()` that one
349
+ * of those modules awaits included; once all have, the entry's body is
350
+ * running or waiting on a top-level await, and asked for react-x11 itself.
351
+ * Read with Bun's own scanner and resolver, and what they cannot answer
352
+ * throws, which reads as the start.
353
+ */
354
+ function bunStartedRunning(bun, moduleUrl, entry) {
355
+ const cache = createRequire(entry).cache;
356
+ // the entry has finished
357
+ if (BUN_MAIN in cache) return true;
358
+ const outer = outermostFile();
359
+ // a CommonJS entry: late unless on its first run
360
+ if (entry in cache) return outer !== undefined && outer !== entry;
361
+ let own = null;
362
+ try {
363
+ own = fileURLToPath(moduleUrl);
364
+ } catch {
365
+ // a CommonJS bundle has no import.meta.url
366
+ }
367
+ // react-x11 inside the entry, or loaded by code below it on the stack
368
+ if (own === entry || (outer !== undefined && outer !== own)) return false;
369
+ // reached by an import: late once the entry's body has started
370
+ const { imports } = new bun.Transpiler({
371
+ loader: SCAN_LOADER[path.extname(entry)] ?? 'jsx',
372
+ }).scan(fs.readFileSync(entry, 'utf8'));
373
+ const dir = path.dirname(entry);
374
+ return imports.every(
375
+ ({ kind, path: specifier }) =>
376
+ kind !== 'import-statement' || bun.resolveSync(specifier, dir) in cache,
377
+ );
378
+ }
379
+
380
+ /**
381
+ * The file of the outermost frame on this stack that is not native code,
382
+ * or undefined where the stack cannot be read.
383
+ */
384
+ function outermostFile() {
385
+ const sites = callSites();
386
+ if (!sites) return undefined;
387
+ for (let i = sites.length - 1; i >= 0; i--) {
388
+ if (!sites[i].isNative()) return sites[i].getFileName() ?? undefined;
389
+ }
390
+ return undefined;
391
+ }
392
+
393
+ /**
394
+ * This stack as call sites, every frame of it, or null where it cannot be
395
+ * read as call sites — an `Error` the app has frozen, say. Raises the stack
396
+ * trace limit and sets a formatter for as long as it takes, and puts back
397
+ * the app's own.
398
+ */
399
+ function callSites() {
296
400
  const { prepareStackTrace, stackTraceLimit } = Error;
297
401
  let sites;
298
402
  try {
299
403
  // Reflect.set: an Error the app has frozen is not a reason to throw
300
404
  // from its import of react-x11
301
405
  Reflect.set(Error, 'stackTraceLimit', Infinity);
302
- Reflect.set(Error, 'prepareStackTrace', (_, callSites) => callSites);
406
+ Reflect.set(Error, 'prepareStackTrace', (_, frames) => frames);
303
407
  const held = {};
304
408
  Error.captureStackTrace(held);
305
409
  sites = held.stack;
@@ -307,14 +411,7 @@ function evaluatedFromEntryPoint() {
307
411
  Reflect.set(Error, 'prepareStackTrace', prepareStackTrace);
308
412
  Reflect.set(Error, 'stackTraceLimit', stackTraceLimit);
309
413
  }
310
- if (!Array.isArray(sites)) return false;
311
- for (const site of sites) {
312
- const file = site.getFileName() ?? '';
313
- if (ENTRY_POINT.has(file)) return true;
314
- // the app's own code awaits this import, so it has run already
315
- if (site.isAsync() && !file.startsWith('node:')) return false;
316
- }
317
- return false;
414
+ return Array.isArray(sites) ? sites : null;
318
415
  }
319
416
 
320
417
  /**
@@ -0,0 +1,28 @@
1
+ // CSS's `currentColor`: the colour keyword that is not a colour yet.
2
+ //
3
+ // A leaf, like decorations.js, because every layer that reads a colour asks
4
+ // about it — the grammar (styles.js, shorthands.js, decorations.js) to let
5
+ // it through, and the paint (nodes/boxpaint.js and every backend's reader
6
+ // of a style colour) to resolve it.
7
+ //
8
+ // It stays the keyword in `node.style` — CSS's computed value — and becomes
9
+ // a colour only where something is about to paint it, against the node's
10
+ // resolved ink (`Node.resolveColor`). Resolving it once in the style funnel
11
+ // instead would bake the ink into the style object, which no inherited
12
+ // change reaches: a `:hover { color }` on a row moves the ink of every node
13
+ // under it without restyling any of them.
14
+
15
+ /** `currentColor`, spelled any way CSS accepts — its keywords are ASCII
16
+ * case-insensitive — and with the whitespace a style value may carry. */
17
+ const KEYWORD = /^\s*currentcolor\s*$/i;
18
+ const MENTION = /currentcolor/i;
19
+
20
+ /** Whether `value` is the keyword. Asked per colour per paint, so a value
21
+ * too short to be it is answered without the match. */
22
+ export const isCurrentColor = (value) =>
23
+ typeof value === 'string' && value.length >= 12 && KEYWORD.test(value);
24
+
25
+ /** Whether a value made of several — a gradient, a shadow list — names the
26
+ * keyword anywhere in it. */
27
+ export const mentionsCurrentColor = (value) =>
28
+ typeof value === 'string' && value.length >= 12 && MENTION.test(value);
@@ -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
+ }
package/src/errors.js CHANGED
@@ -6,6 +6,14 @@
6
6
  // `data` handler and takes the process with it, or wedges the frame loop,
7
7
  // which is worse: it looks like a hang rather than a crash.
8
8
 
9
+ /**
10
+ * The `code` of the error a drawn element at the root of the tree throws
11
+ * (Reconciler.js, `notAtRoot`). A code because a tool acts on it: `react-x11
12
+ * dev` mounts a component that renders no `<window>` inside one of its own,
13
+ * and the message is prose that is free to change.
14
+ */
15
+ export const NOT_IN_WINDOW = 'NOT_IN_WINDOW';
16
+
9
17
  // container -> the root's onUncaughtError, when it set one
10
18
  const perContainer = new WeakMap();
11
19
 
@@ -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);