react-x11 2.49.0 → 2.51.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.
package/src/cli/dev.js CHANGED
@@ -27,12 +27,26 @@
27
27
  // missing, names them with the versions the loader is written against, and
28
28
  // installs them with the project's own package manager when the developer
29
29
  // says yes. Without a terminal to ask on it prints the command instead.
30
+ // - **It turns click-to-component on.** Alt+Click (Option+Click on macOS)
31
+ // opens the JSX line an element came from (docs/click-to-component.md):
32
+ // a development tool, and this is the command a developer runs, so it is
33
+ // on unless REACT_X11_CLICK_TO_COMPONENT=0 says otherwise. Set here, in
34
+ // the environment, because that is where the renderer reads it when the
35
+ // first root is made — and where a toolchain restart inherits it from.
36
+ // - **It connects to React DevTools** whenever `npx react-devtools` is
37
+ // running, before or after the app started, the way a browser with the
38
+ // extension does: on by default where react-devtools-core is installed,
39
+ // unless REACT_X11_DEVTOOLS=0. Where it is not, and a DevTools UI is
40
+ // listening at start, it says what to install. Prepared before React
41
+ // Refresh loads, because Refresh installs a stub hook where it finds none,
42
+ // and the backend installs none where it finds one.
30
43
  // - **It runs the project's react-x11.** A second copy of the renderer is a
31
44
  // second React root nobody refreshes, so a global or npx-cached
32
45
  // `react-x11` hands over to the copy the file itself imports.
33
46
  import { spawnSync } from 'node:child_process';
34
47
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
35
48
  import * as nodeModule from 'node:module';
49
+ import { connect } from 'node:net';
36
50
  import { basename, dirname, extname, join, relative, resolve } from 'node:path';
37
51
  import { createInterface } from 'node:readline/promises';
38
52
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -122,10 +136,22 @@ export async function runDev(file, options = {}) {
122
136
  return null;
123
137
  }
124
138
 
139
+ process.env.REACT_X11_CLICK_TO_COMPONENT ??= '1';
140
+ if (process.env.REACT_X11_DEVTOOLS === undefined) {
141
+ if (resolves('react-devtools-core')) {
142
+ process.env.REACT_X11_DEVTOOLS = '1';
143
+ } else {
144
+ suggestDevTools({ projectDir, log });
145
+ }
146
+ }
147
+ if (envFlag(process.env.REACT_X11_DEVTOOLS)) {
148
+ await (await import('../DevToolsIntegration.js')).prepare();
149
+ }
150
+
125
151
  // Before anything below loads: a module's source maps are read when it
126
152
  // loads, and only once this is on.
127
153
  process.setSourceMapsEnabled(true);
128
- const { registerRefresh } = await import('../refresh/loader.js');
154
+ const { onTransform, registerRefresh } = await import('../refresh/loader.js');
129
155
  await registerRefresh();
130
156
  // The runtime patches React DevTools' global hook and then hands the
131
157
  // renderer to it, which has to happen before the first root commits.
@@ -135,6 +161,7 @@ export async function runDev(file, options = {}) {
135
161
  const ReactX11 = await import('../index.js');
136
162
  const { NOT_IN_WINDOW } = await import('../errors.js');
137
163
  const { liveApps } = await import('../trace-registry.js');
164
+ const { ErrorWindow, describeError } = await import('./errorwindow.js');
138
165
 
139
166
  // What `node file.jsx …args` would hand the app.
140
167
  process.argv = [process.argv[0], path, ...args];
@@ -210,19 +237,55 @@ export async function runDev(file, options = {}) {
210
237
  const title = `${basename(path)} — react-x11 dev`;
211
238
  let wrapped = false;
212
239
  let mixedNoted = false;
240
+ // What the error window shows: a render error took the app's tree down,
241
+ // and the app stays down until the next reload tries it again; a compile
242
+ // error is a save that did not compile, beside an app still running the
243
+ // module's last version. Both at once shows the compile error, which is
244
+ // the newer news.
245
+ let renderFailure = null;
246
+ let compileFailure = null;
247
+ // Set when a reload starts while a render error is showing: that reload is
248
+ // the one to try the app again on. Not the reload that threw — react-refresh
249
+ // calls the reload listeners in the same batch as the render that failed,
250
+ // and retrying there would only throw it a second time.
251
+ let retry = false;
213
252
  let root = null;
253
+ // Two children, always in these places: an error window opening or
254
+ // closing beside the app is then a sibling coming and going, and the app's
255
+ // state is untouched by it.
214
256
  const tree = () => {
215
257
  const app = React.createElement(App);
216
- return wrapped
217
- ? React.createElement('window', { ...DEV_WINDOW, title }, app)
218
- : app;
258
+ const failure = compileFailure ?? renderFailure;
259
+ return React.createElement(
260
+ React.Fragment,
261
+ null,
262
+ renderFailure
263
+ ? null
264
+ : wrapped
265
+ ? React.createElement('window', { ...DEV_WINDOW, title }, app)
266
+ : app,
267
+ failure
268
+ ? React.createElement(ErrorWindow, {
269
+ failure,
270
+ title: `${basename(failure.filename ?? path)} — error`,
271
+ // Beside a running app it is a second window, whose closing is
272
+ // ours to answer: it goes until the next error. Standing in for
273
+ // the app it is the only one, and closing it ends the app.
274
+ onCloseRequest: renderFailure
275
+ ? undefined
276
+ : () => {
277
+ compileFailure = null;
278
+ render();
279
+ },
280
+ })
281
+ : null,
282
+ );
219
283
  };
220
284
  const render = () => root.render(tree());
221
- const mounted = () => (root.app._rootChildren ?? []).length > 0;
222
285
 
223
286
  root = await (createRoot ?? ReactX11.createRoot)({
224
287
  onUncaughtError(error, info) {
225
- if (error?.code === NOT_IN_WINDOW && !wrapped) {
288
+ if (error?.code === NOT_IN_WINDOW && !wrapped && !renderFailure) {
226
289
  wrapped = true;
227
290
  log(
228
291
  `${name} renders no <window>, so it is shown in one of react-x11 ` +
@@ -241,27 +304,65 @@ export async function runDev(file, options = {}) {
241
304
  );
242
305
  return;
243
306
  }
244
- console.error(
245
- 'react-x11 dev: uncaught error' + (info?.componentStack ?? ''),
307
+ console.error(error);
308
+ const chain = describeError({
309
+ kind: 'render',
246
310
  error,
311
+ componentStack: info?.componentStack,
312
+ }).components;
313
+ // Already showing one: this came out of the error window itself, or
314
+ // the tree failed again before a reload — the terminal has it, and a
315
+ // window answering its own failure by mounting itself again would
316
+ // never stop.
317
+ if (renderFailure) return;
318
+ log(
319
+ (chain.length > 0 ? `thrown rendering ${chain.join(' › ')}. ` : '') +
320
+ `Fix it and save: ${name} is mounted again on the next reload.`,
247
321
  );
248
- setImmediate(() => {
249
- if (!mounted()) {
250
- log(`fix it and save: ${name} is mounted again on the next reload.`);
251
- }
252
- });
322
+ renderFailure = {
323
+ kind: 'render',
324
+ error,
325
+ componentStack: info?.componentStack,
326
+ };
327
+ setImmediate(render);
253
328
  },
254
329
  });
255
330
 
256
- // After a reload has re-rendered the edited components: take the dev
331
+ // A save that does not compile: the reload library prints it and keeps the
332
+ // module's last version, so this is the only place it can be shown. Before
333
+ // the mount, the import of the file itself throws, and the bin prints it.
334
+ //
335
+ // Every reload compiles at least the module it stops at, so this is also
336
+ // where a reload is first seen.
337
+ onTransform(({ filename, error }) => {
338
+ if (error) {
339
+ compileFailure = { kind: 'compile', error, filename };
340
+ setImmediate(render);
341
+ return;
342
+ }
343
+ if (renderFailure) retry = true;
344
+ if (compileFailure?.filename === filename) {
345
+ compileFailure = null;
346
+ setImmediate(render);
347
+ }
348
+ });
349
+
350
+ // After a reload: try an app an error took down again, and take the dev
257
351
  // window away from a tree that now brings its own. The other direction
258
352
  // needs nothing here — a tree that lost its window throws NOT_IN_WINDOW,
259
- // and the handler above wraps it — and neither does a tree an error took
260
- // down: react-refresh keeps the roots that failed, and renders them again
261
- // on the next reload itself.
353
+ // and the handler above wraps it.
262
354
  const settle = () => {
263
- if (!wrapped || !mounted()) return;
264
- const { windows, drawn } = rootContent(root.app._rootChildren[0]);
355
+ if (renderFailure) {
356
+ if (!retry) return;
357
+ retry = false;
358
+ renderFailure = null;
359
+ render();
360
+ return;
361
+ }
362
+ if (!wrapped) return;
363
+ const devWindow = (root.app._rootChildren ?? [])[0];
364
+ if (!devWindow) return;
365
+ const { windows, drawn } = rootContent(devWindow);
265
366
  if (windows > 0 && drawn === 0) {
266
367
  wrapped = false;
267
368
  mixedNoted = false;
@@ -311,6 +412,48 @@ function rootContent(node) {
311
412
  return { windows, drawn };
312
413
  }
313
414
 
415
+ /** Whether `specifier` resolves from react-x11, which is where it is
416
+ * imported from. */
417
+ function resolves(specifier) {
418
+ try {
419
+ import.meta.resolve(specifier);
420
+ return true;
421
+ } catch {
422
+ return false;
423
+ }
424
+ }
425
+
426
+ /** The renderer's rule for a switch: set, and not `0`. */
427
+ function envFlag(value) {
428
+ return Boolean(value) && value !== '0';
429
+ }
430
+
431
+ /**
432
+ * Where react-devtools-core is missing: if a DevTools UI is listening (one
433
+ * TCP connect, in the background, never holding the process), say what to
434
+ * install for the app to show up in it. Silent otherwise — no DevTools
435
+ * running is the usual case, not one to print about.
436
+ */
437
+ function suggestDevTools({ projectDir, log }) {
438
+ const host = process.env.REACT_X11_DEVTOOLS_HOST || 'localhost';
439
+ const port = Number(process.env.REACT_X11_DEVTOOLS_PORT) || 8097;
440
+ const socket = connect({ host, port });
441
+ socket.unref();
442
+ socket.setTimeout(1000, () => socket.destroy());
443
+ socket.on('error', () => {});
444
+ socket.on('connect', () => {
445
+ socket.destroy();
446
+ const line = shellLine(
447
+ installCommand(packageManagerFor(projectDir), ['react-devtools-core']),
448
+ );
449
+ log(
450
+ `React DevTools is listening on ${host}:${port}, and this project ` +
451
+ 'cannot connect to it without react-devtools-core. Install it, and ' +
452
+ `the next run shows up there:\n\n ${line}\n`,
453
+ );
454
+ });
455
+ }
456
+
314
457
  /** The react-x11 package `path` imports, or null when it imports none. */
315
458
  export function reactX11For(path) {
316
459
  let entry;
@@ -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
+ }
package/src/cli/index.js CHANGED
@@ -17,6 +17,10 @@ Commands:
17
17
  export is mounted, inside a window of its own when it
18
18
  renders none; a file that mounts its own root runs as it is.
19
19
  Arguments after the file are the app's.
20
+ Alt+Click (Option+Click on macOS) an element to open its
21
+ source; REACT_X11_CLICK_TO_COMPONENT=0 turns that off.
22
+ Connects to React DevTools (npx react-devtools) whenever
23
+ it runs; REACT_X11_DEVTOOLS=0 turns that off.
20
24
 
21
25
  Options:
22
26
  -h, --help Show this help.
package/src/cocoa/app.js CHANGED
@@ -1447,6 +1447,9 @@ export class CocoaApp {
1447
1447
  return this._animationEnds.get(ev.id)?.(ev);
1448
1448
  case 'window-live-resize':
1449
1449
  return this._routeLiveResize(ev);
1450
+ case 'window-fullscreen':
1451
+ case 'window-miniaturize':
1452
+ return this._routeWindowState(ev);
1450
1453
  case 'window-created':
1451
1454
  // a worker's window, made on the UI thread after the call that
1452
1455
  // answered its handle
@@ -1611,6 +1614,8 @@ export class CocoaApp {
1611
1614
  // this is the only thing the bridge says about it (`_recheckScreens`).
1612
1615
  this._recheckScreens();
1613
1616
  wnd._nativeResized(ev);
1617
+ // AppKit says nothing of a zoom but the resize it makes
1618
+ if (ev.type === 'window-resize') wnd._refreshStates();
1614
1619
  wnd.emit('resize', {
1615
1620
  width: wnd.width,
1616
1621
  height: wnd.height,
@@ -1676,6 +1681,22 @@ export class CocoaApp {
1676
1681
  wnd.emit('liveresize', { live: wnd.liveResizing });
1677
1682
  }
1678
1683
 
1684
+ /**
1685
+ * `window-fullscreen { fullscreen, phase }` and `window-miniaturize`
1686
+ * (@windowkit/appkit 0.30.0): the title bar's green button, the Dock, and
1687
+ * the same asked for through `setWmState`. The bridge has published the
1688
+ * state already — a full-screen transition's target from its `begin`,
1689
+ * before the animation, which is the moment an app takes its chrome away
1690
+ * — so the window reads it back and says `statechange` if it moved. A
1691
+ * user's own act, so it is input: its render goes out like a click's.
1692
+ */
1693
+ _routeWindowState(ev) {
1694
+ const wnd = this._window(ev);
1695
+ if (!wnd || wnd.destroyed) return;
1696
+ wnd._refreshStates();
1697
+ this._afterInput();
1698
+ }
1699
+
1679
1700
  /**
1680
1701
  * `surface-released`: a worker's frame took an IOSurface off a layer and
1681
1702
  * the frame that replaced it has committed (windowkit/appkit#52). The
@@ -1759,6 +1780,7 @@ export class CocoaApp {
1759
1780
  const wnd = this._window(ev);
1760
1781
  if (!wnd) return;
1761
1782
  if (name === 'focus') this._lastKeyWindow = wnd;
1783
+ wnd._refreshStates();
1762
1784
  wnd.emit(name, { buttons: 0, time: ev.time });
1763
1785
  this._afterInput();
1764
1786
  }
@@ -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,