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.
@@ -0,0 +1,368 @@
1
+ // The window `react-x11 dev` shows an error in: what was thrown, the source
2
+ // around where, and the frames of the stack, each of which opens its line in
3
+ // the editor (src/editor.js, the one click-to-component uses).
4
+ //
5
+ // Two kinds of error reach it. A **render** error takes the app's tree down —
6
+ // React unmounts a root whose render threw and nothing caught — so this
7
+ // window stands in for the app until the next save mounts it again. A
8
+ // **compile** error is a save that did not compile: the app keeps running the
9
+ // module's last version, and this window opens beside it until a save
10
+ // compiles.
11
+ //
12
+ // Everything the window says comes out of `describeError`, which is pure, so
13
+ // the tests can read it without rendering anything.
14
+ import { readFileSync } from 'node:fs';
15
+ import { relative } from 'node:path';
16
+ import React from 'react';
17
+ import { editorName, openInEditor, stackFrames } from '../editor.js';
18
+
19
+ const h = React.createElement;
20
+
21
+ /** Lines of source shown above and below the one that threw. */
22
+ const CONTEXT_ABOVE = 3;
23
+ const CONTEXT_BELOW = 2;
24
+
25
+ function firstLine(text) {
26
+ const at = text.indexOf('\n');
27
+ return at === -1 ? text : text.slice(0, at);
28
+ }
29
+
30
+ /**
31
+ * The source lines around `line` (1-based) of `file`: `{ number, text, at }`
32
+ * each, `at` marking the one that threw. Null when the file cannot be read.
33
+ */
34
+ function excerptOf(file, line) {
35
+ let source;
36
+ try {
37
+ source = readFileSync(file, 'utf8');
38
+ } catch {
39
+ return null;
40
+ }
41
+ const lines = source.split('\n');
42
+ if (line < 1 || line > lines.length) return null;
43
+ const from = Math.max(1, line - CONTEXT_ABOVE);
44
+ const to = Math.min(lines.length, line + CONTEXT_BELOW);
45
+ const excerpt = [];
46
+ for (let number = from; number <= to; number++) {
47
+ excerpt.push({
48
+ number,
49
+ // a tab is as wide as its font says, and two spaces is what an editor
50
+ // with a two-space indent shows
51
+ text: lines[number - 1].replace(/\t/g, ' ').replace(/\r$/, ''),
52
+ at: number === line,
53
+ });
54
+ }
55
+ return excerpt;
56
+ }
57
+
58
+ /**
59
+ * Everything the window says about one failure, as data.
60
+ *
61
+ * `failure` is `{ kind, error, filename?, componentStack? }`: `kind` is
62
+ * `'render'` or `'compile'`, `filename` the module that did not compile, and
63
+ * `componentStack` what React handed `onUncaughtError`.
64
+ */
65
+ export function describeError(failure, cwd = process.cwd()) {
66
+ const { kind, error, filename = null, componentStack = '' } = failure;
67
+ const rel = (file) => relative(cwd, file) || file;
68
+ const message = String(error?.message ?? error);
69
+ const name = error?.name && error.name !== 'Error' ? error.name : 'Error';
70
+
71
+ let heading;
72
+ let detail = '';
73
+ let location = null;
74
+ if (kind === 'compile') {
75
+ // babel says `<file>: <what> (<line>:<column>)` and then draws a frame;
76
+ // the window draws its own from `loc`, when there is one
77
+ let what = firstLine(message);
78
+ if (filename && what.startsWith(`${filename}: `)) {
79
+ what = what.slice(filename.length + 2);
80
+ }
81
+ what = what.replace(/ \(\d+:\d+\)$/, '');
82
+ heading = `${name}: ${what}`;
83
+ if (filename && error?.loc?.line) {
84
+ location = {
85
+ file: filename,
86
+ line: error.loc.line,
87
+ column: (error.loc.column ?? 0) + 1,
88
+ };
89
+ } else {
90
+ detail = message.slice(firstLine(message).length).replace(/^\n+/, '');
91
+ }
92
+ } else {
93
+ heading = `${name}: ${firstLine(message)}`;
94
+ detail = message.slice(firstLine(message).length).replace(/^\n+/, '');
95
+ }
96
+
97
+ // A compile error's stack is the compiler's: where in babel the parse
98
+ // gave up, which says nothing about the app.
99
+ const all = kind === 'compile' ? [] : stackFrames(error?.stack);
100
+ const shown = all.filter((frame) => !frame.internal);
101
+ if (!location) {
102
+ const own = shown.find((frame) => !frame.installed) ?? shown[0];
103
+ if (own) {
104
+ location = { file: own.file, line: own.line, column: own.column };
105
+ }
106
+ }
107
+
108
+ // React's component stack, innermost first: `at Broken (…)`. Host
109
+ // elements are in it too; the breadcrumb is the components.
110
+ const components = [];
111
+ for (const text of String(componentStack).split('\n')) {
112
+ const match = text.match(/^\s*at ([A-Z][\w$.]*)/);
113
+ if (match) components.push(match[1]);
114
+ }
115
+
116
+ return {
117
+ kind,
118
+ heading,
119
+ detail,
120
+ location: location && {
121
+ ...location,
122
+ where: `${rel(location.file)}:${location.line}:${location.column}`,
123
+ },
124
+ excerpt: location ? excerptOf(location.file, location.line) : null,
125
+ frames: shown.map((frame) => ({
126
+ label: frame.functionName ?? '(anonymous)',
127
+ where: `${rel(frame.file)}:${frame.line}:${frame.column}`,
128
+ file: frame.file,
129
+ line: frame.line,
130
+ column: frame.column,
131
+ own: !frame.installed,
132
+ })),
133
+ hidden: all.length - shown.length,
134
+ components: components.reverse(),
135
+ };
136
+ }
137
+
138
+ const mono = { fontFamily: '$monoFamily', fontSize: 12 };
139
+
140
+ /**
141
+ * A row that opens a source line: drawn pressed on the press and hovered
142
+ * under the pointer, because a click that opens another application takes a
143
+ * moment to show anything of its own.
144
+ */
145
+ function SourceLink({ target, children }) {
146
+ const open = () => openInEditor(target);
147
+ return h(
148
+ 'box',
149
+ {
150
+ focusable: true,
151
+ role: 'link',
152
+ onClick: open,
153
+ onKeyDown: (ev) => {
154
+ if (ev.key === 'Enter') open();
155
+ },
156
+ style: {
157
+ flexDirection: 'row',
158
+ gap: 10,
159
+ paddingTop: 3,
160
+ paddingBottom: 3,
161
+ paddingStart: 6,
162
+ paddingEnd: 6,
163
+ borderRadius: '$radiusSmall',
164
+ cursor: 'pointer',
165
+ ':hover': { backgroundColor: '$surfaceHover' },
166
+ ':active': { backgroundColor: '$surfaceActive' },
167
+ },
168
+ },
169
+ children,
170
+ );
171
+ }
172
+
173
+ function Excerpt({ excerpt, column }) {
174
+ const width = String(excerpt[excerpt.length - 1].number).length;
175
+ const rows = [];
176
+ for (const { number, text, at } of excerpt) {
177
+ rows.push(
178
+ h(
179
+ 'box',
180
+ { key: number, style: { flexDirection: 'row' } },
181
+ h(
182
+ 'text',
183
+ {
184
+ style: {
185
+ ...mono,
186
+ color: at ? '$danger' : '$textMuted',
187
+ textWrap: 'nowrap',
188
+ },
189
+ },
190
+ `${at ? '>' : ' '} ${String(number).padStart(width)} │ `,
191
+ ),
192
+ h(
193
+ 'text',
194
+ {
195
+ style: {
196
+ ...mono,
197
+ color: at ? '$text' : '$textMuted',
198
+ textWrap: 'nowrap',
199
+ flexShrink: 1,
200
+ },
201
+ },
202
+ text || ' ',
203
+ ),
204
+ ),
205
+ );
206
+ if (at && column > 0) {
207
+ rows.push(
208
+ h(
209
+ 'text',
210
+ {
211
+ key: 'caret',
212
+ style: { ...mono, color: '$danger', textWrap: 'nowrap' },
213
+ },
214
+ `${' '.repeat(width + 5 + column - 1)}^`,
215
+ ),
216
+ );
217
+ }
218
+ }
219
+ return h(
220
+ 'box',
221
+ {
222
+ style: {
223
+ backgroundColor: '$surfaceHover',
224
+ borderRadius: '$radius',
225
+ paddingTop: 10,
226
+ paddingBottom: 10,
227
+ paddingStart: 12,
228
+ paddingEnd: 12,
229
+ overflow: 'hidden',
230
+ },
231
+ },
232
+ rows,
233
+ );
234
+ }
235
+
236
+ /**
237
+ * The window itself. `onCloseRequest` is the host's to give: without one,
238
+ * closing the only window there is ends the app, as closing an app's primary
239
+ * window does.
240
+ */
241
+ export function ErrorWindow({ failure, title, onCloseRequest }) {
242
+ const info = describeError(failure);
243
+ const muted = { color: '$textMuted', fontSize: 12 };
244
+ const editor = editorName();
245
+ const children = [
246
+ h(
247
+ 'text',
248
+ { key: 'kind', style: { ...muted, fontWeight: 600 } },
249
+ info.kind === 'compile'
250
+ ? 'This save did not compile. The app keeps running the last version that did.'
251
+ : 'The app threw while rendering, and React took its tree down.',
252
+ ),
253
+ h(
254
+ 'text',
255
+ {
256
+ key: 'heading',
257
+ style: { color: '$danger', fontSize: 16, fontWeight: 600 },
258
+ },
259
+ info.heading,
260
+ ),
261
+ ];
262
+ if (info.components.length > 0) {
263
+ children.push(
264
+ h(
265
+ 'text',
266
+ { key: 'components', style: muted },
267
+ info.components.join(' › '),
268
+ ),
269
+ );
270
+ }
271
+ if (info.detail) {
272
+ children.push(
273
+ h(
274
+ 'text',
275
+ { key: 'detail', style: { ...mono, color: '$text' } },
276
+ info.detail,
277
+ ),
278
+ );
279
+ }
280
+ if (info.location) {
281
+ children.push(
282
+ h(
283
+ 'box',
284
+ { key: 'source', style: { gap: 6 } },
285
+ h(
286
+ SourceLink,
287
+ { target: info.location },
288
+ h(
289
+ 'text',
290
+ { style: { ...mono, color: '$link' } },
291
+ info.location.where,
292
+ ),
293
+ ),
294
+ info.excerpt
295
+ ? h(Excerpt, { excerpt: info.excerpt, column: info.location.column })
296
+ : null,
297
+ ),
298
+ );
299
+ }
300
+ if (info.frames.length > 0) {
301
+ children.push(
302
+ h(
303
+ 'box',
304
+ { key: 'frames', style: { gap: 1 } },
305
+ h(
306
+ 'text',
307
+ { style: { ...muted, fontWeight: 600, marginBottom: 4 } },
308
+ 'Stack',
309
+ ),
310
+ info.frames.map((frame, i) =>
311
+ h(
312
+ SourceLink,
313
+ { key: i, target: frame },
314
+ h(
315
+ 'text',
316
+ {
317
+ style: {
318
+ ...mono,
319
+ color: frame.own ? '$text' : '$textMuted',
320
+ fontWeight: frame.own ? 600 : 400,
321
+ },
322
+ },
323
+ frame.label,
324
+ ),
325
+ h(
326
+ 'text',
327
+ { style: { ...mono, color: '$textMuted', flexShrink: 1 } },
328
+ frame.where,
329
+ ),
330
+ ),
331
+ ),
332
+ info.hidden > 0
333
+ ? h(
334
+ 'text',
335
+ { style: { ...muted, marginTop: 4, marginLeft: 6 } },
336
+ `${info.hidden} more ${info.hidden === 1 ? 'frame' : 'frames'} in React, Node and react-x11, not shown.`,
337
+ )
338
+ : null,
339
+ ),
340
+ );
341
+ }
342
+ children.push(
343
+ h(
344
+ 'text',
345
+ { key: 'footer', style: { ...muted, marginTop: 4 } },
346
+ (info.kind === 'compile'
347
+ ? 'Fix it and save: this window goes once the file compiles. '
348
+ : 'Fix it and save: the app is mounted again on the next reload. ') +
349
+ `Click a line to open it in ${editor}` +
350
+ (process.env.REACT_X11_EDITOR
351
+ ? '.'
352
+ : '; REACT_X11_EDITOR names another editor.'),
353
+ ),
354
+ );
355
+
356
+ return h(
357
+ 'window',
358
+ { title, width: 760, minHeight: 240, onCloseRequest },
359
+ h(
360
+ 'box',
361
+ {
362
+ selectable: true,
363
+ style: { flexGrow: 1, padding: 20, gap: 14, overflow: 'scroll' },
364
+ },
365
+ children,
366
+ ),
367
+ );
368
+ }
@@ -502,7 +502,9 @@ export function propBoxProps(node, app, scale, parentOrigin, order) {
502
502
  const style = node.style ?? {};
503
503
  const border = typeof style.borderWidth === 'number' ? style.borderWidth : 0;
504
504
  const colour = (value) =>
505
- value ? (app._parseColor(String(value)) ?? [0, 0, 0, 0]) : [0, 0, 0, 0];
505
+ value
506
+ ? (app._parseColor(String(node.resolveColor(value))) ?? [0, 0, 0, 0])
507
+ : [0, 0, 0, 0];
506
508
  return {
507
509
  frame: [
508
510
  (abs.x - parentOrigin.x) / scale,
@@ -42,7 +42,7 @@ import { now } from '../nodes/animation.js';
42
42
  import { BoxNode } from '../nodes/box.js';
43
43
  import { addDamageRect, damageToPaint } from '../nodes/damage.js';
44
44
  import { intersectRects } from '../nodes/rects.js';
45
- import { resolveBorderWidths } from '../styles.js';
45
+ import { radiusOf, resolveBorderWidths } from '../styles.js';
46
46
  import {
47
47
  LayerAnimations,
48
48
  RASTER_PAD,
@@ -841,7 +841,7 @@ export class CocoaPromotion {
841
841
  cut &&
842
842
  !parent.isWindow &&
843
843
  parent.clipsChildren?.() &&
844
- !(parent.style?.borderRadius > 0)
844
+ !(radiusOf(parent.style, parent.abs) > 0)
845
845
  ) {
846
846
  const inner = insetRect(parent.abs, borderReach(parent));
847
847
  // a clip with round corners cut again is no one box's shape: the
@@ -872,13 +872,15 @@ export class CocoaPromotion {
872
872
  !cut.clip &&
873
873
  !parent.isWindow &&
874
874
  parent.clipsChildren?.() &&
875
- typeof parent.style?.borderRadius === 'number' &&
876
- parent.style.borderRadius > 0 &&
877
- !containsRect(insetRect(parent.abs, parent.style.borderRadius), bounds)
875
+ radiusOf(parent.style, parent.abs) > 0 &&
876
+ !containsRect(
877
+ insetRect(parent.abs, radiusOf(parent.style, parent.abs)),
878
+ bounds,
879
+ )
878
880
  ) {
879
881
  const border = borderReach(parent);
880
882
  cut.clip = insetRect(parent.abs, border);
881
- cut.radius = Math.max(0, parent.style.borderRadius - border);
883
+ cut.radius = Math.max(0, radiusOf(parent.style, parent.abs) - border);
882
884
  roundCut = true;
883
885
  bounds = intersectRects(bounds, cut.clip);
884
886
  if (!bounds) {
@@ -906,11 +908,8 @@ export class CocoaPromotion {
906
908
  return false;
907
909
  }
908
910
  if (parent.clipsChildren?.() && !roundCut) {
909
- const radius = parent.style?.borderRadius;
910
- const clip =
911
- typeof radius === 'number' && radius > 0
912
- ? insetRect(parent.abs, radius)
913
- : parent.abs;
911
+ const radius = radiusOf(parent.style, parent.abs);
912
+ const clip = radius > 0 ? insetRect(parent.abs, radius) : parent.abs;
914
913
  if (!containsRect(clip, bounds)) return false;
915
914
  }
916
915
  }
@@ -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);