react-x11 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,8 +3,16 @@
3
3
  // build-step-free for consumers.
4
4
 
5
5
  import React, { useEffect, useRef, useState } from 'react';
6
+ import { useAppOrNull } from '../appcontext.js';
6
7
  import { capBand, capTrim, rowRadius, useTheme } from './theme.js';
7
8
  import { Icon } from './Icon.js';
9
+ import {
10
+ ABS_FILL,
11
+ Bezel,
12
+ bezelNatural,
13
+ pressWash,
14
+ useNativeControls,
15
+ } from './native.js';
8
16
  import { changeEvent } from './change.js';
9
17
  import {
10
18
  anchorArea,
@@ -184,10 +192,13 @@ export function Select({
184
192
  onChange,
185
193
  name,
186
194
  placeholder = 'Select…',
195
+ native,
187
196
  style,
188
197
  ...boxProps
189
198
  }) {
190
199
  const theme = useTheme();
200
+ const app = useAppOrNull();
201
+ const nativeControls = useNativeControls(native);
191
202
  const [open, setOpen] = useState(false);
192
203
  const [anchor, setAnchor] = useState(null);
193
204
  const [focused, setFocused] = useState(false);
@@ -336,25 +347,38 @@ export function Select({
336
347
  onKeyDown,
337
348
  ...boxProps,
338
349
  style: [
339
- {
340
- cursor: 'pointer',
341
- flexDirection: 'row',
342
- alignItems: 'center',
343
- gap: 8,
344
- // The vertical padding is the palette's, the same one a `<Button>`
345
- // takes, because these are controls of one family and a form puts
346
- // them in a row together. Horizontally it is its own, tighter
347
- // number: a dropdown is a field with a value in it, not a button
348
- // with a word centred on it.
349
- paddingTop: theme.paddingY,
350
- paddingBottom: theme.paddingY,
351
- paddingLeft: 10,
352
- paddingRight: 10,
353
- borderWidth: theme.borderWidth,
354
- borderRadius: theme.radius,
355
- borderColor: focused || open ? theme.borderFocus : theme.border,
356
- backgroundColor: theme.surface,
357
- },
350
+ nativeControls
351
+ ? // The native popup bezel carries the border, the fill and the
352
+ // arrow capsule, so the trigger keeps only its row layout and
353
+ // AppKit's own height. The right padding clears the arrows.
354
+ {
355
+ cursor: 'pointer',
356
+ flexDirection: 'row',
357
+ alignItems: 'center',
358
+ gap: 8,
359
+ height: bezelNatural(app, 'popup').height,
360
+ paddingLeft: 10,
361
+ paddingRight: 26,
362
+ }
363
+ : {
364
+ cursor: 'pointer',
365
+ flexDirection: 'row',
366
+ alignItems: 'center',
367
+ gap: 8,
368
+ // The vertical padding is the palette's, the same one a `<Button>`
369
+ // takes, because these are controls of one family and a form puts
370
+ // them in a row together. Horizontally it is its own, tighter
371
+ // number: a dropdown is a field with a value in it, not a button
372
+ // with a word centred on it.
373
+ paddingTop: theme.paddingY,
374
+ paddingBottom: theme.paddingY,
375
+ paddingLeft: 10,
376
+ paddingRight: 10,
377
+ borderWidth: theme.borderWidth,
378
+ borderRadius: theme.radius,
379
+ borderColor: focused || open ? theme.borderFocus : theme.border,
380
+ backgroundColor: theme.surface,
381
+ },
358
382
  // Hover and press belong to the trigger while it is *shut*: they say
359
383
  // "this opens". Once the menu is down that is answered, and the
360
384
  // trigger's job is to read as one surface with the popup hanging off
@@ -364,13 +388,25 @@ export function Select({
364
388
  // state block always outranks the base style, so there is no colour
365
389
  // the open state could put in `backgroundColor` that `:hover` would
366
390
  // not overwrite. The only way for open to win is to not be competing.
367
- !open && {
368
- ':hover': { backgroundColor: theme.surfaceHover },
369
- ':active': { backgroundColor: theme.surfaceActive },
370
- },
391
+ // (In native mode the wash overlay below answers the press instead.)
392
+ !nativeControls &&
393
+ !open && {
394
+ ':hover': { backgroundColor: theme.surfaceHover },
395
+ ':active': { backgroundColor: theme.surfaceActive },
396
+ },
371
397
  style,
372
398
  ],
373
399
  },
400
+ // `pressed` while the menu is down: AppKit's popup answers being open
401
+ // by highlighting the arrow capsule, which is the open look this
402
+ // trigger otherwise lost with its borderFocus.
403
+ nativeControls &&
404
+ h(Bezel, {
405
+ kind: 'popup',
406
+ pressed: open,
407
+ enabled: true,
408
+ style: ABS_FILL,
409
+ }),
374
410
  h(
375
411
  'text',
376
412
  { style: [capTrim, { color: current ? theme.text : theme.textMuted }] },
@@ -382,11 +418,20 @@ export function Select({
382
418
  // row have to be one height, and they only are if the tallest thing in
383
419
  // each is measured the same way. A glyph taller than the cap band makes
384
420
  // the dropdown alone two pixels taller than everything next to it.
385
- h(Icon, {
386
- name: 'chevronDown',
387
- size: capBand(theme.fontSize),
388
- color: theme.textMuted,
389
- }),
421
+ // The native bezel draws its own arrow capsule instead.
422
+ nativeControls
423
+ ? h('box', {
424
+ style: [
425
+ ABS_FILL,
426
+ { borderRadius: 6 },
427
+ !open && { ':active': { backgroundColor: pressWash(theme) } },
428
+ ],
429
+ })
430
+ : h(Icon, {
431
+ name: 'chevronDown',
432
+ size: capBand(theme.fontSize),
433
+ color: theme.textMuted,
434
+ }),
390
435
  open &&
391
436
  anchor &&
392
437
  h(
@@ -3,6 +3,8 @@
3
3
  // build-step-free for consumers.
4
4
 
5
5
  import React, { useRef, useState } from 'react';
6
+ import { useAppOrNull } from '../appcontext.js';
7
+ import { Bezel, bezelNatural, useNativeControls } from './native.js';
6
8
  import { useTheme } from './theme.js';
7
9
  import { changeEvent } from './change.js';
8
10
  import {
@@ -42,10 +44,13 @@ export function Slider({
42
44
  name,
43
45
  disabled = false,
44
46
  height = 4,
47
+ native,
45
48
  style,
46
49
  ...boxProps
47
50
  }) {
48
51
  const theme = useTheme();
52
+ const app = useAppOrNull();
53
+ const nativeControls = useNativeControls(native);
49
54
  const [focused, setFocused] = useState(false);
50
55
  const [dragging, setDragging] = useState(false);
51
56
  const trackRef = useRef(null);
@@ -152,6 +157,60 @@ export function Slider({
152
157
  },
153
158
  };
154
159
 
160
+ // The whole control as one NSSlider render, keyed by the value so a drag
161
+ // is a sequence of cached-or-rendered bezels. Pointer math, keyboard and
162
+ // a11y are the shared implementation above — the bezel is presentation.
163
+ //
164
+ // The *small* control size on purpose: its 16pt knob is exactly the
165
+ // drawn thumb's footprint, so rows laid out for the drawn slider fit,
166
+ // and the pointer math shares one thumb constant across both modes —
167
+ // the regular size's 20pt knob overflowed every compact row it met.
168
+ if (nativeControls) {
169
+ const nat = bezelNatural(app, 'slider', 'small');
170
+ return h(
171
+ 'box',
172
+ {
173
+ theme,
174
+ role: 'slider',
175
+ 'aria-valuenow': clamp(value),
176
+ 'aria-valuemin': min,
177
+ 'aria-valuemax': max,
178
+ 'aria-orientation': 'horizontal',
179
+ ref: trackRef,
180
+ ...controlProps,
181
+ ...boxProps,
182
+ style: [
183
+ disabled || { cursor: 'pointer' },
184
+ {
185
+ height: nat.height,
186
+ minWidth: 0,
187
+ hitSlop: {
188
+ top: Math.max(0, (24 - nat.height) / 2),
189
+ bottom: Math.max(0, (24 - nat.height) / 2),
190
+ },
191
+ },
192
+ style,
193
+ ],
194
+ },
195
+ h(Bezel, {
196
+ kind: 'slider',
197
+ controlSize: 'small',
198
+ // quantized so a drag reuses cache entries instead of minting one
199
+ // per sub-pixel float; 400 steps is finer than any track is wide
200
+ value: Math.round(fraction * 400) / 400,
201
+ enabled: !disabled,
202
+ style: {
203
+ position: 'absolute',
204
+ top: 0,
205
+ right: 0,
206
+ bottom: 0,
207
+ left: 0,
208
+ pointerEvents: 'none',
209
+ },
210
+ }),
211
+ );
212
+ }
213
+
155
214
  return h(
156
215
  'box',
157
216
  {
@@ -3,8 +3,16 @@
3
3
  // build-step-free for consumers.
4
4
 
5
5
  import React from 'react';
6
+ import { useAppOrNull } from '../appcontext.js';
6
7
  import { createStyles } from '../styles.js';
7
8
  import { changeEvent } from './change.js';
9
+ import {
10
+ ABS_FILL,
11
+ Bezel,
12
+ bezelNatural,
13
+ pressWash,
14
+ useNativeControls,
15
+ } from './native.js';
8
16
  import { useControl, useTheme } from './theme.js';
9
17
 
10
18
  const h = React.createElement;
@@ -74,15 +82,59 @@ export function Switch({
74
82
  onChange,
75
83
  name,
76
84
  disabled = false,
85
+ native,
77
86
  style,
78
87
  ...boxProps
79
88
  }) {
80
89
  const theme = useTheme();
90
+ const app = useAppOrNull();
91
+ const nativeControls = useNativeControls(native);
81
92
  const control = useControl(
82
93
  disabled,
83
94
  () => onChange?.(changeEvent('checkbox', name, !checked)),
84
95
  { styled: true },
85
96
  );
97
+
98
+ // NSSwitch's own pixels, on/off as two cached bezels. The knob slide is
99
+ // the fidelity this loses for now (docs/macos.md flags the switch as the
100
+ // one control worth revisiting if that disappoints); the press keeps its
101
+ // answer as the wash, since NSSwitch renders no pressed state offscreen.
102
+ if (nativeControls) {
103
+ const nat = bezelNatural(app, 'switch');
104
+ return h(
105
+ 'box',
106
+ {
107
+ theme,
108
+ role: 'switch',
109
+ 'aria-checked': checked,
110
+ ...control.props,
111
+ ...boxProps,
112
+ style: [
113
+ control.style,
114
+ {
115
+ width: nat.width,
116
+ height: nat.height,
117
+ hitSlop: Math.max(0, (24 - nat.height) / 2),
118
+ },
119
+ style,
120
+ ],
121
+ },
122
+ h(Bezel, {
123
+ kind: 'switch',
124
+ state: checked ? 1 : 0,
125
+ enabled: !disabled,
126
+ style: ABS_FILL,
127
+ }),
128
+ h('box', {
129
+ style: [
130
+ ABS_FILL,
131
+ { borderRadius: nat.height / 2 },
132
+ !disabled && { ':active': { backgroundColor: pressWash(theme) } },
133
+ ],
134
+ }),
135
+ );
136
+ }
137
+
86
138
  return h(
87
139
  'box',
88
140
  {
@@ -0,0 +1,142 @@
1
+ // The native half of the widget set (docs/macos.md §Native controls).
2
+ //
3
+ // House rule: **default native, seam out.** On a backend that renders the
4
+ // platform's own control bezels — today the Cocoa backend — the core
5
+ // controls wear AppKit's pixels while interaction, focus, keyboard and
6
+ // a11y stay the shared implementation. The mechanism is a bezel *image*
7
+ // drawn through the ordinary paint path, never an embedded platform view:
8
+ // the press model, the focus rules and the event routing are behaviour this
9
+ // project considers part of its identity, and a real NSControl would take
10
+ // them over wholesale.
11
+ //
12
+ // The policy lives in the theme (`controls: 'auto' | 'native' | 'drawn'`),
13
+ // because whether an app looks native is an app-identity decision, not a
14
+ // per-node style. The per-instance escape hatch is `native={false}` on the
15
+ // one custom-branded control — a themed `style` override is ignored by a
16
+ // native bezel, so a control that names its own colours should also name
17
+ // `native={false}` (the widgets keep today's drawn rendering everywhere the
18
+ // bezel is off, so custom-designed apps lose nothing).
19
+
20
+ import React, { useCallback } from 'react';
21
+ import { useAppOrNull } from '../appcontext.js';
22
+ import { useTheme } from './theme.js';
23
+
24
+ const h = React.createElement;
25
+
26
+ let warnedUnsupported = false;
27
+
28
+ /**
29
+ * Should this control render the platform bezel? Combines the per-instance
30
+ * `native` prop, the theme's `controls` policy and the backend capability.
31
+ * An explicit `controls: 'native'` on a backend without the capability
32
+ * warns once and draws — erroring would make the same app code illegal on
33
+ * X11, which is the opposite of what a cross-backend widget set is for.
34
+ */
35
+ export function useNativeControls(native) {
36
+ const app = useAppOrNull();
37
+ const theme = useTheme();
38
+ const capable = Boolean(app?.nativeBezels);
39
+ if (native === false) return false;
40
+ const mode = native === true ? 'native' : (theme.controls ?? 'auto');
41
+ if (mode === 'drawn') return false;
42
+ if (mode === 'native' && !capable && !warnedUnsupported) {
43
+ warnedUnsupported = true;
44
+ console.warn(
45
+ "react-x11: controls: 'native' — this backend has no native control " +
46
+ 'rendering; the themed drawn controls are used instead.',
47
+ );
48
+ }
49
+ return capable;
50
+ }
51
+
52
+ /**
53
+ * The control's natural size in logical px — AppKit's own metrics, which
54
+ * native-mode layout adopts (a stretched checkbox is a wrong checkbox).
55
+ * `null` where there is no bezel store, so callers can guard in one step.
56
+ */
57
+ export function bezelNatural(app, kind, controlSize = 'regular') {
58
+ return app?.nativeBezels?.natural(kind, controlSize) ?? null;
59
+ }
60
+
61
+ /** Absolute fill inside the control's box — where every bezel layer goes. */
62
+ export const ABS_FILL = Object.freeze({
63
+ position: 'absolute',
64
+ top: 0,
65
+ right: 0,
66
+ bottom: 0,
67
+ left: 0,
68
+ });
69
+
70
+ /**
71
+ * The press wash — the one hover/press affordance native mode keeps.
72
+ * AppKit's controls have no hover state, so hover tints are deliberately
73
+ * absent here; the press is answered with a translucent wash over the
74
+ * bezel, dark on light and light on dark, the direction macOS itself steps
75
+ * a pressed control.
76
+ */
77
+ export const pressWash = (theme) =>
78
+ theme.scheme === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.07)';
79
+
80
+ /**
81
+ * `<Bezel kind …states style>` — one AppKit bezel, rendered at exactly the
82
+ * box layout gives it. A `<canvas>` under the hood: the bezel comes out of
83
+ * the app's `BezelStore` (cached by every parameter that changes the
84
+ * pixels) and is blitted with the 9-arg `drawImage`, so a state change is a
85
+ * cache lookup and one blit, bounded to this node's damage.
86
+ */
87
+ export function Bezel({
88
+ kind,
89
+ state = 0,
90
+ pressed = false,
91
+ enabled = true,
92
+ isDefault = false,
93
+ value,
94
+ controlSize = 'regular',
95
+ style,
96
+ }) {
97
+ const app = useAppOrNull();
98
+ const theme = useTheme();
99
+ // The theme in force decides the bezel's appearance, not the desktop: a
100
+ // pinned-light app on a dark desktop gets light bezels beside its light
101
+ // surfaces. `scheme` is the palette's own statement of which it is.
102
+ const appearance = theme.scheme === 'dark' ? 'dark' : 'light';
103
+ const params = {
104
+ kind,
105
+ controlSize,
106
+ state: state ? 1 : 0,
107
+ pressed: Boolean(pressed),
108
+ enabled: Boolean(enabled),
109
+ isDefault: Boolean(isDefault),
110
+ appearance,
111
+ };
112
+ if (value !== undefined) params.value = value;
113
+ const sig = JSON.stringify(params);
114
+ const store = app?.nativeBezels;
115
+ // Keyed on the parameter signature so an unchanged bezel keeps its
116
+ // `onDraw` identity — CanvasNode invalidates when the closure changes,
117
+ // and a Button re-rendered by its parent must not repaint its bezel.
118
+ const onDraw = useCallback(
119
+ (ctx, info) => {
120
+ if (!store || !info.width || !info.height) return;
121
+ const bezel = store.get(
122
+ JSON.parse(sig),
123
+ info.width,
124
+ info.height,
125
+ info.scale,
126
+ );
127
+ ctx.drawImage(
128
+ { _surfaceHandle: bezel.surface },
129
+ bezel.sx,
130
+ bezel.sy,
131
+ bezel.sw,
132
+ bezel.sh,
133
+ 0,
134
+ 0,
135
+ info.width,
136
+ info.height,
137
+ );
138
+ },
139
+ [store, sig],
140
+ );
141
+ return h('canvas', { onDraw, style });
142
+ }
@@ -30,6 +30,12 @@
30
30
  // - `textShadow`: per glyph, through the glyph cache, and much rarer in a
31
31
  // desktop UI.
32
32
 
33
+ // The kernel maths is ntk's: `blurCoverage` runs the blur, so the reach it
34
+ // derives is the padding a coverage surface has to carry. Both are pure
35
+ // functions — no connection, nothing drawn — so this module stays testable
36
+ // without a server.
37
+ import { shadowReach, shadowSigma } from 'ntk';
38
+
33
39
  /** A number, with or without the `px` CSS wants and this vocabulary does not. */
34
40
  const LENGTH = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:px)?$/i;
35
41
  /** …and the same thing as a percentage, which colour stops also take. */
@@ -366,36 +372,27 @@ function readShadow(part, value) {
366
372
  }
367
373
 
368
374
  /**
369
- * The gaussian a CSS blur radius means, and the room it needs.
375
+ * The gaussian a `blur` asks for, and the padding its coverage surface needs.
370
376
  *
371
- * CSS defines the blur radius as *twice* the standard deviation, and ntk's
372
- * `Picture.setBlurFilter(size, sigma)` takes the convolution kernel's edge
373
- * length rather than a radius — so the two names that look like they match
374
- * are the two that must not be passed to each other. The kernel is cut at
375
- * 3σ, where the gaussian is down to 1% and the truncation is invisible;
376
- * `pad` is the same distance, and it is what stops the blur clipping square
377
- * against the edge of the surface it was rendered into.
377
+ * CSS defines the blur radius as *twice* the standard deviation, so the two
378
+ * names that look like they match are the two that must not be passed to
379
+ * each other; `shadowSigma` is the halving, and it applies ntk's policy cap
380
+ * so a 4000px blur asked for by accident cannot become a kernel nothing
381
+ * finishes.
378
382
  *
379
- * `MAX_KERNEL` bounds a pathological value: the server convolves taps per
380
- * pixel, so a 200px blur asked for by accident would otherwise be a frame
381
- * that never lands.
383
+ * `pad` is the reach of the kernel **ntk will actually run** — that is why
384
+ * it comes from `shadowReach` rather than from arithmetic of our own. The
385
+ * blur is baked by `blurCoverage`, which derives its own kernel from sigma,
386
+ * and a surface padded by less than that reach ends the shadow in a straight
387
+ * line where the kernel ran out of pixels. The two numbers have to agree and
388
+ * nothing about a wrong answer looks like a bug, so they come from one
389
+ * place. The extra pixel is slack against the rounding above.
382
390
  */
383
- const MAX_KERNEL = 61;
384
-
385
391
  export function blurKernel(blur) {
386
- const sigma = blur / 2;
387
- const reach = Math.ceil(3 * sigma);
388
- const size = Math.min(MAX_KERNEL, 2 * reach + 1);
389
- return { sigma, size, pad: (size - 1) / 2 + 1 };
392
+ const sigma = shadowSigma(blur);
393
+ return { sigma, pad: sigma > 0 ? shadowReach(sigma) + 1 : 0 };
390
394
  }
391
395
 
392
- /**
393
- * How far outside the node's own box this shadow list reaches, in device
394
- * pixels — the damage inflation, and the reason a shadow is not just a
395
- * colour. Symmetric on purpose: a shadow offset down and right claims the
396
- * same slack above and left, which costs a few pixels of repaint and saves
397
- * every caller from carrying four numbers around.
398
- */
399
396
  export function shadowExtent(shadows) {
400
397
  let extent = 0;
401
398
  for (const s of shadows ?? []) {
package/src/editmenu.js CHANGED
@@ -21,6 +21,11 @@
21
21
  */
22
22
  import { formatShortcut, isSeparator } from './menuitem.js';
23
23
 
24
+ // Logical px, multiplied by the display scale where they are used: the
25
+ // menu's text is measured and painted in device pixels (fonts are sized on
26
+ // the device grid), so the chrome around it has to live on the same grid —
27
+ // unscaled constants against scaled glyph ink is rows shorter than their
28
+ // own labels (the compact-menu bug, first seen on the 2x Cocoa backend).
24
29
  const ROW_HEIGHT = 22;
25
30
  const SEPARATOR_HEIGHT = 7;
26
31
  const PAD_X = 10;
@@ -143,33 +148,35 @@ export function editMenuItems(actions = {}, { canPaste = true } = {}) {
143
148
  * @param {(text: string) => number|null} [measure] text width, or null when
144
149
  * nothing can be measured yet
145
150
  */
146
- export function editMenuGeometry(items, measure) {
151
+ export function editMenuGeometry(items, measure, scale = 1) {
147
152
  const widthOf = (text) => {
148
153
  if (!text) return 0;
149
154
  const w = measure?.(text);
150
155
  return typeof w === 'number' && w > 0
151
156
  ? w
152
- : text.length * FALLBACK_CHAR_WIDTH;
157
+ : text.length * FALLBACK_CHAR_WIDTH * scale;
153
158
  };
154
159
  const rows = [];
155
- let y = PAD_Y;
160
+ let y = PAD_Y * scale;
156
161
  let widest = 0;
157
162
  for (const item of items) {
158
163
  const separator = isSeparator(item);
159
164
  const shortcut = formatShortcut(item.shortcut);
160
- const height = separator ? SEPARATOR_HEIGHT : ROW_HEIGHT;
165
+ const height = (separator ? SEPARATOR_HEIGHT : ROW_HEIGHT) * scale;
161
166
  rows.push({ ...item, separator, shortcut, y, height });
162
167
  y += height;
163
168
  if (separator) continue;
164
169
  widest = Math.max(
165
170
  widest,
166
- widthOf(item.label) + (shortcut ? SHORTCUT_GAP + widthOf(shortcut) : 0),
171
+ widthOf(item.label) +
172
+ (shortcut ? SHORTCUT_GAP * scale + widthOf(shortcut) : 0),
167
173
  );
168
174
  }
169
175
  return {
170
176
  rows,
171
- width: Math.max(MIN_WIDTH, Math.ceil(widest + PAD_X * 2)),
172
- height: Math.ceil(y + PAD_Y),
177
+ scale,
178
+ width: Math.max(MIN_WIDTH * scale, Math.ceil(widest + PAD_X * scale * 2)),
179
+ height: Math.ceil(y + PAD_Y * scale),
173
180
  };
174
181
  }
175
182
 
@@ -220,12 +227,14 @@ export function paintEditMenu(
220
227
  { geometry, active = -1, colors = EDIT_MENU_COLORS, layoutOf, radius = 4 },
221
228
  ) {
222
229
  const { width, height, rows } = geometry;
230
+ const s = geometry.scale ?? 1;
231
+ const padX = PAD_X * s;
223
232
 
224
- panelPath(ctx, width, height, radius);
233
+ panelPath(ctx, width, height, radius * s);
225
234
  ctx.fillStyle = colors.background;
226
235
  ctx.fill();
227
236
  ctx.strokeStyle = colors.border;
228
- ctx.lineWidth = 1;
237
+ ctx.lineWidth = s;
229
238
  ctx.stroke();
230
239
 
231
240
  const label = (text, color, x, row) => {
@@ -241,10 +250,10 @@ export function paintEditMenu(
241
250
  if (row.separator) {
242
251
  ctx.fillStyle = colors.separator;
243
252
  ctx.fillRect(
244
- PAD_X,
253
+ padX,
245
254
  Math.round(row.y + row.height / 2) + 0.5,
246
- width - PAD_X * 2,
247
- 1,
255
+ width - padX * 2,
256
+ s,
248
257
  );
249
258
  continue;
250
259
  }
@@ -259,14 +268,14 @@ export function paintEditMenu(
259
268
  : on
260
269
  ? colors.highlightText
261
270
  : colors.text;
262
- label(row.label, color, PAD_X, row);
271
+ label(row.label, color, padX, row);
263
272
  if (row.shortcut) {
264
273
  // the shortcut column stays quieter than the label, except in the
265
274
  // highlighted row where it has to stay legible on the accent
266
275
  const shortcutColor = on ? colors.highlightText : colors.dim;
267
276
  const layout = layoutOf?.(row.shortcut, shortcutColor);
268
277
  const w = layout?.width ?? 0;
269
- label(row.shortcut, shortcutColor, width - PAD_X - w, row);
278
+ label(row.shortcut, shortcutColor, width - padX - w, row);
270
279
  }
271
280
  }
272
281
  }
package/src/errors.js CHANGED
@@ -59,6 +59,48 @@ export function reportHandlerError(node, handler, error) {
59
59
  markFailed();
60
60
  }
61
61
 
62
+ /**
63
+ * `REACT_X11_STRICT_TOKENS=1` makes a `$token` the theme does not define
64
+ * fatal again, for a build that would rather stop than paint something
65
+ * wrong. The default reports and carries on — see `reportStyleError`.
66
+ *
67
+ * Guarded rather than a bare `process.env` because the playground bundle
68
+ * runs in a browser, where there is no `process` at all.
69
+ */
70
+ export const STRICT_TOKENS =
71
+ (typeof process === 'undefined'
72
+ ? undefined
73
+ : process.env?.REACT_X11_STRICT_TOKENS) === '1';
74
+
75
+ /** Messages already printed, so a shared misspelled style reports once per
76
+ * (node, message) rather than once per restyle — a theme swap re-resolves
77
+ * the whole subtree and would otherwise print the same line every time. */
78
+ const reportedStyleErrors = new WeakMap();
79
+
80
+ /**
81
+ * A style the node cannot resolve — today only an unknown `$token`.
82
+ *
83
+ * Not a throw, and deliberately: the mistake is one property in one style,
84
+ * and the tree it would take down is the whole GUI. The property is dropped
85
+ * (so the widget paints without it, visibly wrong), the message names the
86
+ * token and whose element wore it, and `process.exitCode` is set so a test
87
+ * run or a supervisor still counts this as a failure. `REACT_X11_STRICT_TOKENS=1`
88
+ * restores the throw.
89
+ */
90
+ export function reportStyleError(node, message) {
91
+ const seen = reportedStyleErrors.get(node);
92
+ if (seen?.has(message)) return;
93
+ if (seen) seen.add(message);
94
+ else reportedStyleErrors.set(node, new Set([message]));
95
+ const owner = ownerName(node);
96
+ console.error(
97
+ `${message}${owner ? ` — in ${owner}` : ''}. ` +
98
+ 'The property is dropped and the app carries on; set ' +
99
+ 'REACT_X11_STRICT_TOKENS=1 to make this throw instead.',
100
+ );
101
+ markFailed();
102
+ }
103
+
62
104
  /** Wrap a call to user code so a throw is reported instead of escaping. */
63
105
  export function callHandler(node, handler, fn, ev) {
64
106
  try {