react-x11 2.23.0 → 2.23.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.23.0",
3
+ "version": "2.23.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -103,7 +103,7 @@
103
103
  "dependencies": {
104
104
  "ical.js": "^2.2.1",
105
105
  "linebreak": "^1.1.0",
106
- "ntk": "^8.10.0",
106
+ "ntk": "^8.14.3",
107
107
  "react-reconciler": "^0.34.0",
108
108
  "yoga-layout": "^3.2.1"
109
109
  },
@@ -252,7 +252,6 @@ export class CocoaPaneWindow {
252
252
  this._dirty = false;
253
253
  const shown = this._ring[this._drawIndex];
254
254
  this._native.surfaceUnlock(shown.handle);
255
- const damage = this._flushDamage;
256
255
  this._flushDamage = null;
257
256
  this.app._paneSend?.({
258
257
  type: 'pane-present',
@@ -270,16 +269,17 @@ export class CocoaPaneWindow {
270
269
  this._surface = next.handle;
271
270
  this._surfaceGen++;
272
271
  this._native.surfaceLock(next.handle);
273
- // A full-repaint frame overwrites everything next, so no catch-up is
274
- // needed. A partial frame paints only its damage, so `next` must first
275
- // hold the last complete frame underneath — and the WHOLE of it, not
276
- // just this frame's rects, because in a three-buffer ring `next` was
277
- // last drawn two presents ago and is stale everywhere. A full copy of
278
- // the just-shown buffer is the complete background; the safe target is
279
- // what triple buffering buys.
280
- if (damage !== 'full' && damage) {
281
- this._native.copySurfaceRegion(shown.handle, next.handle, null);
282
- }
272
+ // The next frame may paint only its damage, so `next` must first hold
273
+ // the last complete frame underneath — and the WHOLE of it, not just
274
+ // this frame's rects, because in a three-buffer ring `next` was last
275
+ // drawn two presents ago and is stale everywhere. A full copy of the
276
+ // just-shown buffer is the complete background; the safe target is what
277
+ // triple buffering buys. After a full frame as much as a partial one:
278
+ // what a frame was says nothing of what the next will be, and skipping
279
+ // the copy there had a partial frame after a full one — a status line,
280
+ // a hover's underline — drawn over the page as it was two frames back.
281
+ // The copy is wasted only where the next frame is full as well.
282
+ this._native.copySurfaceRegion(shown.handle, next.handle, null);
283
283
  }
284
284
 
285
285
  snapshot() {
@@ -34,6 +34,7 @@
34
34
  // derives is the padding a coverage surface has to carry. Both are pure
35
35
  // functions — no connection, nothing drawn — so this module stays testable
36
36
  // without a server.
37
+ import { cssColorStraight } from 'ntk/color';
37
38
  import { shadowReach, shadowSigma } from 'ntk/shadow-math';
38
39
 
39
40
  /** A number, with or without the `px` CSS wants and this vocabulary does not. */
@@ -199,11 +200,33 @@ function readStop(part, value) {
199
200
  if (pieces.length > 1 && (PERCENT.test(last) || LENGTH.test(last))) {
200
201
  pieces.pop();
201
202
  return {
202
- color: pieces.join(' '),
203
+ color: stopColor(pieces.join(' '), value),
203
204
  position: { value: number(last), unit: PERCENT.test(last) ? '%' : 'px' },
204
205
  };
205
206
  }
206
- return { color: part, position: null };
207
+ return { color: stopColor(part, value), position: null };
208
+ }
209
+
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. */
212
+ function stopColor(color, value) {
213
+ if (isColorOrToken(color)) return color;
214
+ throw new Error(
215
+ `react-x11: backgroundImage ${JSON.stringify(value)} has a colour stop ` +
216
+ `that is not a colour ("${color}"; expected one like '#2980b9', ` +
217
+ "'tomato' or 'rgba(0, 0, 0, .4)', or a $token, before its position)",
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Whether a piece of a gradient or a shadow can stand where its colour does.
223
+ * One that mentions a `$token` is let through where the style is written,
224
+ * since the token means nothing yet; the paint parses the value it resolves
225
+ * to, and a colour that is still not one drops the whole decoration there
226
+ * rather than throwing (see "memoized parsing" below).
227
+ */
228
+ function isColorOrToken(color) {
229
+ return color.includes('$') || cssColorStraight(color) !== null;
207
230
  }
208
231
 
209
232
  /**
@@ -366,6 +389,9 @@ function readShadow(part, value) {
366
389
  color = piece;
367
390
  }
368
391
  if (lengths.length < 2) throw bad('needs an x and a y offset');
392
+ if (color !== null && !isColorOrToken(color)) {
393
+ throw bad(`has "${color}", which is neither a length nor a colour`);
394
+ }
369
395
  const [dx, dy, blur = 0, spread = 0] = lengths;
370
396
  if (blur < 0) throw bad('has a negative blur radius');
371
397
  return { dx, dy, blur, spread, color };
@@ -4,6 +4,7 @@
4
4
 
5
5
  import {
6
6
  DEFAULT_FOCUS_RING,
7
+ isColor,
7
8
  resolveBorderWidths,
8
9
  resolveBorderColors,
9
10
  } from '../styles.js';
@@ -22,11 +23,14 @@ import { ntkRoot, x11Ntk } from '../ntkroot.js';
22
23
  import { isFocusable as a11yFocusable } from '../a11y.js';
23
24
  import { DEV } from './util.js';
24
25
 
25
- /** CSS's `transparent` keyword means "paint nothing". ntk's colour parser
26
- * does not know it and throws deep inside the 2d context, taking the whole
27
- * frame with it, so filter it out at the source alongside null/''. */
26
+ /** Whether a style's colour paints anything. CSS's `transparent` keyword
27
+ * means "paint nothing" and is filtered out at the source alongside
28
+ * null/'', and so is a value that is not a colour at all (`isColor`): ntk
29
+ * throws on one deep inside the 2d context, taking the whole frame with
30
+ * it. Everything that plans a paint asks this too — the blits deciding
31
+ * what a box covers — so a colour that is dropped is dropped everywhere. */
28
32
  export function isPaintedColor(color) {
29
- return Boolean(color) && color !== 'transparent';
33
+ return Boolean(color) && color !== 'transparent' && isColor(color);
30
34
  }
31
35
 
32
36
  /**
@@ -4,6 +4,7 @@
4
4
 
5
5
  import {
6
6
  applyLayoutStyle,
7
+ isColor,
7
8
  textStyleFrom,
8
9
  DEFAULT_TEXT_STYLE,
9
10
  localTextStyleChanged,
@@ -332,7 +333,9 @@ export class NodeCascade {
332
333
  return this._textScaled.style;
333
334
  }
334
335
  const theme = this.theme;
335
- const color = theme.text;
336
+ // a palette is app-written, and an ink no engine parses would throw from
337
+ // every paragraph's draw (`isColor`)
338
+ const color = isColor(theme.text) ? theme.text : DEFAULT_TEXT_STYLE.color;
336
339
  // A palette can reach a node as a bare `theme` **prop** rather than a
337
340
  // resolved one — `<box theme={{ text: 'red' }}>` merges and derives
338
341
  // nothing (styling.md) — so neither of these is guaranteed to be there.
@@ -681,7 +681,7 @@ export class WindowPaint {
681
681
  // repainting the background only where it is about to be drawn over is
682
682
  // the other half of the win: a full-window fill is a full-window
683
683
  // composite however little changed
684
- fill(backgroundColor || this.theme.background, false);
684
+ fill(this._windowBackground(), false);
685
685
  if (gradient) fill(gradient, false);
686
686
  }
687
687
  }
package/src/nodes/text.js CHANGED
@@ -37,6 +37,9 @@ const STRIP_COVERAGE = 0.45;
37
37
 
38
38
  const textStripBelow = new WeakMap();
39
39
 
40
+ /** `TextNode._trim`'s answer, per layout. */
41
+ const trims = new WeakMap();
42
+
40
43
  /** The size under which this connection's text is painted as strips. */
41
44
  export function setTextStripBelow(app, below) {
42
45
  if (below === undefined) return;
@@ -410,13 +413,28 @@ export class TextNode extends Node {
410
413
  if (!fonts) return null; // mock container in tests: no text metrics
411
414
  const maxLines = this._maxLines();
412
415
  const overflow = this.style.textOverflow;
413
- // Both truncation options are inputs to the shaping, so both belong in
414
- // the key. They can only change with the style, which clears the whole
415
- // map on its way past — but a cache keyed on less than it depends on is
416
- // one refactor away from answering with the wrong paragraph, and the
417
- // wrong paragraph here is glyphs on screen that no error mentions.
418
- const key = `${maxWidth}|${maxLines}|${overflow ?? ''}`;
419
- let layout = this._layouts.get(key);
416
+ // Both truncation options are inputs to the shaping, so a hit has to
417
+ // match them as well as the width. They can only change with the style,
418
+ // which clears the whole map on its way past — but a cache keyed on less
419
+ // than it depends on is one refactor away from answering with the wrong
420
+ // paragraph, and the wrong paragraph here is glyphs on screen that no
421
+ // error mentions.
422
+ //
423
+ // The width is the key as the number it is, and the other two are
424
+ // checked on the entry, rather than all three spelled into one string: a
425
+ // layout pass measures every label several times over, and turning a
426
+ // fractional width into text cost more than the rest of a hit together.
427
+ const entry = this._layouts.get(maxWidth);
428
+ let layout =
429
+ entry !== undefined &&
430
+ entry.maxLines === maxLines &&
431
+ entry.overflow === overflow
432
+ ? entry.layout
433
+ : null;
434
+ if (!layout) {
435
+ layout = this._shapedWider(maxWidth, maxLines, overflow);
436
+ if (layout) this._layouts.set(maxWidth, { layout, maxLines, overflow });
437
+ }
420
438
  if (!layout) {
421
439
  const spans = this.collectSpans([]);
422
440
  const base = this.resolvedTextStyle();
@@ -440,11 +458,52 @@ export class TextNode extends Node {
440
458
  direction: this.direction,
441
459
  });
442
460
  if (this._layouts.size > 32) this._layouts.clear();
443
- this._layouts.set(key, layout);
461
+ this._layouts.set(maxWidth, { layout, maxLines, overflow });
444
462
  }
445
463
  return layout;
446
464
  }
447
465
 
466
+ /**
467
+ * A layout this paragraph already has at a wider width that shaping it at
468
+ * `maxWidth` would come out the same as, or null.
469
+ *
470
+ * ntk fills lines greedily: a line takes tokens while they fit. So if
471
+ * every line a wider layout made fits `maxWidth` as well, the narrower
472
+ * layout breaks at the same tokens — each line was the longest run that
473
+ * fit the wider width, and so is the longest that fits a narrower one it
474
+ * still fits in — and a word too wide for either is cut at the same
475
+ * cluster. Past the breaks, only two things read the width, and both are
476
+ * ruled out here: placing a line anywhere but at the left edge (centred,
477
+ * right-aligned, or the start of a right-to-left paragraph), and eliding
478
+ * one. A line within a thousandth of a pixel of the width is left to be
479
+ * shaped, since the sum the fill tests and the width the line reports are
480
+ * added up in different orders.
481
+ *
482
+ * The paint's request is the common one: a label is laid out in a box the
483
+ * width it measured, a pixel or less narrower than the width it was
484
+ * measured at, and was shaped again for it — 37% of the layouts a fling
485
+ * down a <Tree> built.
486
+ *
487
+ * ntk's layouts only. CoreText and DirectWrite break lines their own way,
488
+ * and their layouts carry no `baseLevel`.
489
+ */
490
+ _shapedWider(maxWidth, maxLines, overflow) {
491
+ if (overflow === 'ellipsis') return null;
492
+ const align = this.style.textAlign;
493
+ if (align !== undefined && align !== 'start' && align !== 'left') {
494
+ return null;
495
+ }
496
+ const fits = maxWidth - 0.001;
497
+ for (const [width, entry] of this._layouts) {
498
+ if (!(width > maxWidth)) continue;
499
+ if (entry.maxLines !== maxLines || entry.overflow !== overflow) continue;
500
+ const layout = entry.layout;
501
+ if (layout?.baseLevel !== 0) continue;
502
+ if (layout.lines.every((line) => line.width <= fits)) return layout;
503
+ }
504
+ return null;
505
+ }
506
+
448
507
  /**
449
508
  * `textBoxTrim: 'cap-alphabetic'` — CSS's `text-box-trim: trim-both` with
450
509
  * `text-box-edge: cap alphabetic`. How much of the line box to take off
@@ -482,6 +541,22 @@ export class TextNode extends Node {
482
541
  if (this.style.textBoxTrim !== 'cap-alphabetic') return null;
483
542
  const lines = layout?.lines;
484
543
  if (!lines?.length) return null;
544
+ // Once per layout rather than once per measure, which a layout pass asks
545
+ // for several times over. A layout never changes once it is shaped, and
546
+ // the face and size read below are the ones it was shaped in: a style
547
+ // that moves either drops every layout on its way past
548
+ // (`_textContentChanged`), so the answer cannot go stale while its layout
549
+ // is still being handed out.
550
+ let trim = trims.get(layout);
551
+ if (trim === undefined) {
552
+ trim = this._trimOf(layout, lines);
553
+ trims.set(layout, trim);
554
+ }
555
+ return trim;
556
+ }
557
+
558
+ /** `_trim`'s answer, worked out. */
559
+ _trimOf(layout, lines) {
485
560
  const base = this.resolvedTextStyle();
486
561
  const font = this.app?.fonts?.match?.(base.family, {
487
562
  weight: base.weight,
@@ -8,6 +8,7 @@ import {
8
8
  transparencyDisabled,
9
9
  watchCompositing,
10
10
  } from '../../compositing.js';
11
+ import { isColor } from '../../styles.js';
11
12
  import { DEV } from '../util.js';
12
13
 
13
14
  /**
@@ -129,9 +130,17 @@ export class WindowCapabilities {
129
130
  * window that cannot be transparent is a square opaque one, not a broken
130
131
  * one — and the alternative, black corners, is worse than square.
131
132
  */
132
- /** What this window paints as its background — its own, or the palette's. */
133
+ /**
134
+ * What this window paints as its background — its own, or the palette's.
135
+ * A colour that does not parse is not one named (`isColor`), and a window
136
+ * has to answer with one: an opaque window cannot paint nothing, since
137
+ * whatever it skipped would show the last frame's pixels.
138
+ */
133
139
  _windowBackground() {
134
- return this.style.backgroundColor || this.theme.background;
140
+ const own = this.style.backgroundColor;
141
+ if (isColor(own)) return own;
142
+ const palette = this.theme.background;
143
+ return isColor(palette) ? palette : 'white';
135
144
  }
136
145
 
137
146
  /**
@@ -27,8 +27,8 @@
27
27
  // are left unmeasured, which is what the floors do with every extent
28
28
  // nobody reads (`collectFloorStale`).
29
29
  //
30
- // Anything else — a row on the way, a margin across the block, a width
31
- // that is not a length — is not a spine, and the tree is measured whole.
30
+ // Anything else — a row on the way, a margin or a width that is not a
31
+ // length — is not a spine, and the tree is measured whole.
32
32
 
33
33
  import { declaresOwnMinimum, inFlow, mainAxisOf } from './floors.js';
34
34
  import { Yoga } from '../../yoga.js';
@@ -118,6 +118,7 @@ export function spineScope(root, sources, listOnly) {
118
118
  const roots = new Map();
119
119
  const spine = new Set();
120
120
  const stops = new Set();
121
+ const offered = new OfferedWidths(root);
121
122
  for (const source of sources) {
122
123
  const list = listOnly(source);
123
124
  // the way up to where the change stops mattering
@@ -148,7 +149,7 @@ export function spineScope(root, sources, listOnly) {
148
149
  if (!child.yoga || child.isWindow || child.style.display === 'none') {
149
150
  continue;
150
151
  }
151
- const widths = rootWidths(child, root);
152
+ const widths = rootWidths(child, offered);
152
153
  if (widths === null) return null;
153
154
  roots.set(child, widths);
154
155
  }
@@ -225,30 +226,90 @@ function staleChildren(node) {
225
226
 
226
227
  /**
227
228
  * The widths a box is measured alone at, or null when it cannot be: in flow
228
- * and stretched across its column with no margin, width or ceiling of its
229
- * own across it, so that it is exactly as wide as the column's content box.
230
- * `width` is that box now — the width its heights are for — and
231
- * `widthPass` the one the width pass over the whole tree would give it
229
+ * and stretched across its column with no width or ceiling of its own
230
+ * across it, so that it is as wide as the column's content box less its
231
+ * margins across. `width` is that box now — the width its heights are for —
232
+ * and `widthPass` the one the width pass over the whole tree would give it
232
233
  * (`widthPassWidth`), null where only a pass knows.
234
+ *
235
+ * Both are the width the box is laid out *in*, not its own: yoga lays a
236
+ * root with no width of its own out at the width on offer less its margins,
237
+ * which is what stretching it across the column does. So a margin across is
238
+ * a number the layout takes off either way — a list whose rows are inset
239
+ * from its edges, as a menu's are, is still a spine — and only a margin
240
+ * that is not a length, a percentage or `auto`, ends one: `auto` centres the
241
+ * box instead of stretching it.
233
242
  */
234
- function rootWidths(node, root) {
243
+ function rootWidths(node, offered) {
235
244
  const style = node.style;
236
245
  const parent = node.parent;
237
246
  if (!parent || !inFlow(node) || node._host !== null) return null;
238
247
  if (style.width !== undefined || style.maxWidth !== undefined) return null;
239
248
  if (typeof style.minWidth === 'string') return null;
240
249
  if (!stretched(node)) return null;
241
- if (
242
- LENGTHS_ACROSS.some((key) => style[key] !== undefined && style[key] !== 0)
243
- ) {
244
- return null;
245
- }
246
250
  if (MARGINS.some((key) => typeof style[key] === 'string')) return null;
247
251
  if (PADDINGS.some((key) => typeof style[key] === 'string')) return null;
248
- return {
249
- width: contentWidth(parent),
250
- widthPass: widthPassContent(parent, root),
251
- };
252
+ return offered.in(parent);
253
+ }
254
+
255
+ /**
256
+ * The widths a column offers the roots in it (`rootWidths`), worked out once
257
+ * a column rather than once a root.
258
+ *
259
+ * Every root in a column is offered the same two, and working out the width
260
+ * pass's walks the way up to the window, with four yoga reads a box for its
261
+ * padding and border across (`insetAcross`). Asked afresh for each root, a
262
+ * fling down a list of rows — a dozen arriving a frame, every one of them a
263
+ * root in the same column — spent 1.8 ms of a 5.9 ms frame on it. Kept for
264
+ * one `spineScope` call, which lays nothing out, so nothing it has read can
265
+ * move under it.
266
+ */
267
+ class OfferedWidths {
268
+ constructor(root) {
269
+ this.root = root;
270
+ this.offers = new Map();
271
+ this.insets = new Map();
272
+ this.passWidths = new Map();
273
+ }
274
+
275
+ /** `{ width, widthPass }` for a root in `column`: its content box now,
276
+ * and in the width pass over the whole tree. */
277
+ in(column) {
278
+ let offer = this.offers.get(column);
279
+ if (offer === undefined) {
280
+ offer = {
281
+ width: Math.max(0, column.yoga.getComputedWidth() - this.inset(column)),
282
+ widthPass: this.passContent(column),
283
+ };
284
+ this.offers.set(column, offer);
285
+ }
286
+ return offer;
287
+ }
288
+
289
+ inset(node) {
290
+ let inset = this.insets.get(node);
291
+ if (inset === undefined) {
292
+ inset = insetAcross(node);
293
+ this.insets.set(node, inset);
294
+ }
295
+ return inset;
296
+ }
297
+
298
+ /** `widthPassWidth`, once a box. */
299
+ passWidth(node) {
300
+ let width = this.passWidths.get(node);
301
+ if (width === undefined) {
302
+ width = widthPassWidth(node, this);
303
+ this.passWidths.set(node, width);
304
+ }
305
+ return width;
306
+ }
307
+
308
+ /** …and the content box inside it. */
309
+ passContent(node) {
310
+ const width = this.passWidth(node);
311
+ return width === null ? null : Math.max(0, width - this.inset(node));
312
+ }
252
313
  }
253
314
 
254
315
  /** Stretched across its parent's column — `alignSelf`, or the column's
@@ -274,11 +335,6 @@ function insetAcross(node) {
274
335
  );
275
336
  }
276
337
 
277
- /** A box's content width in the last pass. */
278
- function contentWidth(node) {
279
- return Math.max(0, node.yoga.getComputedWidth() - insetAcross(node));
280
- }
281
-
282
338
  /**
283
339
  * How wide `node` is in the width pass over the whole tree
284
340
  * (`_measureContentSpans('width')`), worked out on the way down rather than
@@ -288,8 +344,8 @@ function contentWidth(node) {
288
344
  * the way down is anything else: a row, whose children are as wide as the
289
345
  * pass decides.
290
346
  */
291
- function widthPassWidth(node, root) {
292
- if (node === root) return insetAcross(node);
347
+ function widthPassWidth(node, offered) {
348
+ if (node === offered.root) return offered.inset(node);
293
349
  const style = node.style;
294
350
  let width;
295
351
  if (typeof style.width === 'number') width = style.width;
@@ -297,7 +353,7 @@ function widthPassWidth(node, root) {
297
353
  else {
298
354
  if (!inFlow(node) || !stretched(node)) return null;
299
355
  if (MARGINS.some((key) => typeof style[key] === 'string')) return null;
300
- const inner = widthPassContent(node.parent, root);
356
+ const inner = offered.passContent(node.parent);
301
357
  if (inner === null) return null;
302
358
  const yoga = node.yoga;
303
359
  width =
@@ -311,11 +367,5 @@ function widthPassWidth(node, root) {
311
367
  if (typeof style.minWidth === 'number')
312
368
  width = Math.max(width, style.minWidth);
313
369
  else if (style.minWidth !== undefined) return null;
314
- return Math.max(width, insetAcross(node));
315
- }
316
-
317
- /** …and the content box inside it. */
318
- function widthPassContent(node, root) {
319
- const width = widthPassWidth(node, root);
320
- return width === null ? null : Math.max(0, width - insetAcross(node));
370
+ return Math.max(width, offered.inset(node));
321
371
  }
package/src/styles.js CHANGED
@@ -841,6 +841,20 @@ function queryMatches(q, size, supports, containers) {
841
841
 
842
842
  const GRID_VALUES = new Set([...GRID_CONTAINER_PROPS, ...GRID_ITEM_PROPS]);
843
843
 
844
+ // The properties whose value is one colour.
845
+ const COLOR_PROPS = new Set([
846
+ 'backgroundColor',
847
+ 'borderColor',
848
+ 'borderTopColor',
849
+ 'borderRightColor',
850
+ 'borderBottomColor',
851
+ 'borderLeftColor',
852
+ 'borderStartColor',
853
+ 'borderEndColor',
854
+ 'outlineColor',
855
+ 'color',
856
+ ]);
857
+
844
858
  /**
845
859
  * The style values that are a small language rather than a number — a
846
860
  * gradient, a shadow, and grid's track lists, areas and lines — and
@@ -851,8 +865,13 @@ const GRID_VALUES = new Set([...GRID_CONTAINER_PROPS, ...GRID_ITEM_PROPS]);
851
865
  * instead of as a blank panel three commits later. A grid value goes through
852
866
  * the parser the layout itself runs (src/grid.js), so the two agree.
853
867
  *
868
+ * A colour is the smallest of these languages and is checked the same way,
869
+ * on its own and inside a gradient or a shadow: `'nonsense'` or `7` used to
870
+ * pass here and throw from the frame, where no boundary could catch it.
854
871
  * Tokens are still unresolved here (`$accent` is a colour as far as the
855
- * grammar is concerned), so this checks the shape and never the colours.
872
+ * grammar is concerned), so a colour is checked only where it is written
873
+ * out; one a token resolves to is dropped at the paint if it does not parse
874
+ * (`isColor`), since a theme is not a call site.
856
875
  */
857
876
  function validateValue(key, value, where) {
858
877
  const text = TEXT_VALUE_CHECKS.get(key);
@@ -868,6 +887,17 @@ function validateValue(key, value, where) {
868
887
  }
869
888
  return;
870
889
  }
890
+ if (COLOR_PROPS.has(key)) {
891
+ // unset, the way a conditional writes it — `on ? accent : undefined`,
892
+ // or `''`, which has always painted nothing
893
+ if (value == null || value === '') return;
894
+ if (isColor(value) || isToken(value) || mentionsToken(value)) return;
895
+ throw new Error(
896
+ `react-x11: invalid ${key} ${JSON.stringify(value)} in ${where} ` +
897
+ "(expected a CSS colour — '#2980b9', 'tomato', 'rgba(0, 0, 0, 0.5)' " +
898
+ "or 'transparent' — or a $token)",
899
+ );
900
+ }
871
901
  if (key === 'letterSpacing') {
872
902
  if (
873
903
  value != null &&
@@ -1569,6 +1599,34 @@ export function tint(color, alpha) {
1569
1599
  return rgba([c[0], c[1], c[2], c[3] * alpha]);
1570
1600
  }
1571
1601
 
1602
+ // The answer per spelling, kept apart from the parse's own cache because
1603
+ // this is asked once a fill on every paint and wants no copy of the
1604
+ // components. Bounded and dropped whole, like that one: a palette is a few
1605
+ // dozen strings.
1606
+ const colorAnswers = new Map();
1607
+ const COLOR_ANSWERS_MAX = 512;
1608
+
1609
+ /**
1610
+ * Whether `value` is a colour the backends can paint: a string ntk's parser
1611
+ * reads, which is the grammar every context here parses with.
1612
+ *
1613
+ * What is not one is dropped where a style is painted, as CSS drops a
1614
+ * declaration it cannot parse — no fill, no stroke, the inherited ink for
1615
+ * text. The alternatives were worse and differed per backend: ntk throws
1616
+ * from the frame, taking the process with it, and the shared context paints
1617
+ * black. Development says so at the commit instead (`validateValue`).
1618
+ */
1619
+ export function isColor(value) {
1620
+ if (typeof value !== 'string') return false;
1621
+ let known = colorAnswers.get(value);
1622
+ if (known === undefined) {
1623
+ known = cssColorStraight(value) !== null;
1624
+ if (colorAnswers.size >= COLOR_ANSWERS_MAX) colorAnswers.clear();
1625
+ colorAnswers.set(value, known);
1626
+ }
1627
+ return known;
1628
+ }
1629
+
1572
1630
  // ease-out cubic: fast to start, settles gently — the shape almost every UI
1573
1631
  // toolkit defaults to for state changes
1574
1632
  export const ease = (t) => 1 - (1 - t) ** 3;
@@ -2298,7 +2356,9 @@ export function textStyleFrom(props, inherited) {
2298
2356
  // property, like every other name in this vocabulary
2299
2357
  variations: props.fontVariationSettings ?? inherited.variations,
2300
2358
  textRendering: props.textRendering ?? inherited.textRendering,
2301
- color: props.color ?? inherited.color,
2359
+ // an ink no engine can parse is not an ink: the one above is inherited,
2360
+ // as CSS inherits past a `color` it could not read
2361
+ color: isColor(props.color) ? props.color : inherited.color,
2302
2362
  letterSpacing: props.letterSpacing ?? inherited.letterSpacing,
2303
2363
  // Inherited apart, as CSS inherits the two properties apart — a child's
2304
2364
  // `fontVariantNumeric` replaces the keyword above it and leaves an
@@ -42,6 +42,7 @@
42
42
  import { createRequire } from 'node:module';
43
43
  import path from 'node:path';
44
44
  import { pathToFileURL } from 'node:url';
45
+ import { cssColor } from 'ntk/color';
45
46
  import { Image as NtkImage } from 'ntk/image';
46
47
  import { Path2D } from 'ntk/path';
47
48
  import { glDevice } from './device.js';
@@ -238,63 +239,19 @@ const BLACK = Object.freeze([0, 0, 0, 1]);
238
239
  const CLEAR = Object.freeze([0, 0, 0, 0]);
239
240
  const OPAQUE = Object.freeze([1, 1, 1, 1]);
240
241
 
242
+ // ntk's parser, which is the one the other backends read a colour with, so
243
+ // one spelling paints one colour everywhere: this context had a parser of its
244
+ // own, which knew fifteen names and painted `tomato` black. What is not a
245
+ // colour is black here, as it is in the shared context, and core does not
246
+ // hand one over from a style (`isColor` in src/styles.js).
241
247
  function parseColorUncached(value) {
242
248
  const s = value.trim();
243
- if (s[0] === '#') {
244
- const h = s.slice(1);
245
- const n = h.length;
246
- const q = (i, len) =>
247
- parseInt(len === 1 ? h[i] + h[i] : h.slice(i * 2, i * 2 + 2), 16) / 255;
248
- if (n === 3 || n === 4)
249
- return premul(q(0, 1), q(1, 1), q(2, 1), n === 4 ? q(3, 1) : 1);
250
- if (n === 6 || n === 8)
251
- return premul(q(0, 2), q(1, 2), q(2, 2), n === 8 ? q(3, 2) : 1);
252
- }
253
- const m = s.match(/^rgba?\(([^)]+)\)$/i);
254
- if (m) {
255
- const p = m[1].split(/[,\s/]+/).filter(Boolean);
256
- const chan = (v) =>
257
- (v.endsWith('%') ? parseFloat(v) * 2.55 : parseFloat(v)) / 255;
258
- const alpha =
259
- p.length > 3
260
- ? p[3].endsWith('%')
261
- ? parseFloat(p[3]) / 100
262
- : parseFloat(p[3])
263
- : 1;
264
- return premul(
265
- chan(p[0] || '0'),
266
- chan(p[1] || '0'),
267
- chan(p[2] || '0'),
268
- Number.isFinite(alpha) ? alpha : 1,
269
- );
270
- }
271
- if (s === 'transparent' || s === 'none') return CLEAR;
272
- const named = NAMED[s.toLowerCase()];
273
- if (named) return named;
274
- return BLACK;
249
+ // SVG's word for no paint, which this context has always read as clear
250
+ if (s === 'none') return CLEAR;
251
+ const c = cssColor(s);
252
+ return c ? Object.freeze(c) : BLACK;
275
253
  }
276
254
 
277
- function premul(r, g, b, a) {
278
- return Object.freeze([r * a, g * a, b * a, a]);
279
- }
280
-
281
- const NAMED = {
282
- black: BLACK,
283
- white: premul(1, 1, 1, 1),
284
- red: premul(1, 0, 0, 1),
285
- green: premul(0, 128 / 255, 0, 1),
286
- blue: premul(0, 0, 1, 1),
287
- gray: premul(128 / 255, 128 / 255, 128 / 255, 1),
288
- grey: premul(128 / 255, 128 / 255, 128 / 255, 1),
289
- yellow: premul(1, 1, 0, 1),
290
- orange: premul(1, 165 / 255, 0, 1),
291
- purple: premul(128 / 255, 0, 128 / 255, 1),
292
- cyan: premul(0, 1, 1, 1),
293
- magenta: premul(1, 0, 1, 1),
294
- silver: premul(192 / 255, 192 / 255, 192 / 255, 1),
295
- currentcolor: BLACK,
296
- };
297
-
298
255
  /**
299
256
  * A gradient: stops, and a 256-texel ramp texture baked from them on first
300
257
  * use. Interpolated premultiplied, which is what keeps a fade to transparent