react-x11 2.1.4 → 2.2.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 +1 -1
- package/src/Reconciler.js +32 -2
- package/src/a11y.js +8 -0
- package/src/appearance.js +11 -0
- package/src/atspi.js +6 -3
- package/src/bus.js +100 -13
- package/src/desktopintegration.js +98 -0
- package/src/errors.js +42 -0
- package/src/globalmenu.js +5 -0
- package/src/index.d.ts +37 -0
- package/src/nodes.js +102 -9
- package/src/style.d.ts +4 -1
- package/src/styles.js +43 -19
package/package.json
CHANGED
package/src/Reconciler.js
CHANGED
|
@@ -34,7 +34,11 @@ import {
|
|
|
34
34
|
} from './nodes.js';
|
|
35
35
|
import { hasDropProps } from './dnd.js';
|
|
36
36
|
import { AppProvider } from './appcontext.js';
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
defaultRootHandlers,
|
|
39
|
+
setErrorHandler,
|
|
40
|
+
STRICT_TOKENS,
|
|
41
|
+
} from './errors.js';
|
|
38
42
|
import {
|
|
39
43
|
registerApp,
|
|
40
44
|
unregisterApp,
|
|
@@ -53,6 +57,7 @@ import { endIdle } from './idle.js';
|
|
|
53
57
|
import { endKeyboardState } from './keyboardstate.js';
|
|
54
58
|
import { endXSettings } from './xsettings.js';
|
|
55
59
|
import { watchAppearance } from './appearance.js';
|
|
60
|
+
import { setDesktopIntegration } from './desktopintegration.js';
|
|
56
61
|
import { ForeignNode } from './foreignnodes.js';
|
|
57
62
|
import { GlAreaNode } from './glnodes.js';
|
|
58
63
|
import { createRegisteredNode, registeredElements } from './registry.js';
|
|
@@ -302,15 +307,29 @@ const HostConfig = {
|
|
|
302
307
|
// and trapFocus need commitMount too — the node has to be in the tree
|
|
303
308
|
// first, so it can find the EventManager that owns focus. Drop targets
|
|
304
309
|
// likewise: registration needs the root, which insertion assigns.
|
|
310
|
+
//
|
|
311
|
+
// Under REACT_X11_STRICT_TOKENS every token-styled node asks for one as
|
|
312
|
+
// well, since a bad token is only *found* once the node is attached —
|
|
313
|
+
// which is after this ran — and commitMount is the first moment React
|
|
314
|
+
// holds that node's own fiber (nodes.js `_tokenProblem`). Gated on the
|
|
315
|
+
// flag so the default mount pays nothing for a debugging mode.
|
|
305
316
|
return (
|
|
306
317
|
type === 'popup' ||
|
|
307
318
|
Boolean(props.autoFocus) ||
|
|
308
319
|
Boolean(props.trapFocus) ||
|
|
309
|
-
hasDropProps(props)
|
|
320
|
+
hasDropProps(props) ||
|
|
321
|
+
(STRICT_TOKENS && instance._usesTokens)
|
|
310
322
|
);
|
|
311
323
|
},
|
|
312
324
|
|
|
313
325
|
commitMount(instance, type, props) {
|
|
326
|
+
// first, and before any of the work below: the tree is on its way out.
|
|
327
|
+
// `false` afterwards marks this instance's one commitMount spent, so a
|
|
328
|
+
// later re-attach throws at once rather than deferring to a call that
|
|
329
|
+
// will never come (nodes.js `_tokenProblem`).
|
|
330
|
+
const tokenError = instance._tokenError;
|
|
331
|
+
instance._tokenError = false;
|
|
332
|
+
if (tokenError) throw tokenError;
|
|
314
333
|
if (type === 'popup') {
|
|
315
334
|
instance.realize(null);
|
|
316
335
|
}
|
|
@@ -605,6 +624,12 @@ function watchConnection(app, onDisconnect, deliberate) {
|
|
|
605
624
|
*
|
|
606
625
|
* `display`, `fontSource`, `glxVisual` and `onXError` go straight to ntk.
|
|
607
626
|
* Anything else ntk understands, build the client yourself and pass `app`.
|
|
627
|
+
*
|
|
628
|
+
* `desktop: false` turns off the three things this turns on for you that talk
|
|
629
|
+
* to the session bus — the appearance ladder, the accessibility bridge and
|
|
630
|
+
* the global menu — for an embedder that owns them, or a process that must
|
|
631
|
+
* not fork. `desktop: { appearance: false }` names one. See
|
|
632
|
+
* src/desktopintegration.js and docs/desktop.md.
|
|
608
633
|
*/
|
|
609
634
|
export async function createRoot(options = {}) {
|
|
610
635
|
// Before anything builds a node: every drawn node creates a yoga node in
|
|
@@ -627,6 +652,11 @@ export async function createRoot(options = {}) {
|
|
|
627
652
|
}
|
|
628
653
|
const { app: borrowed, onDisconnect, ...rest } = options;
|
|
629
654
|
const owned = borrowed === undefined;
|
|
655
|
+
// Before anything starts, for two reasons: a bad `desktop` shape must throw
|
|
656
|
+
// with nothing in flight, like the check above it — and `startA11y()` below
|
|
657
|
+
// reads this policy, so it has to be settled before the first await, not
|
|
658
|
+
// after (src/desktopintegration.js).
|
|
659
|
+
setDesktopIntegration(rest.desktop);
|
|
630
660
|
// The connection is started first, and the order is the point rather than a
|
|
631
661
|
// detail. `loadLayout()` is only nominally asynchronous: instantiating the
|
|
632
662
|
// engine blocks the event loop for 15-50 ms before it returns its promise
|
package/src/a11y.js
CHANGED
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
// from gi.repository import Atspi; \
|
|
41
41
|
// print({k: int(getattr(Atspi.Role, k)) for k in dir(Atspi.Role) if not k.startswith('_')})"
|
|
42
42
|
|
|
43
|
+
// The one import, and it keeps this file's promise: a `Set` and two functions
|
|
44
|
+
// over it, with no D-Bus, no node builtins and nothing at module scope.
|
|
45
|
+
import { desktopIntegrationEnabled } from './desktopintegration.js';
|
|
46
|
+
|
|
43
47
|
/** AT-SPI role numbers (AtspiRole). */
|
|
44
48
|
export const ATSPI_ROLE = Object.freeze({
|
|
45
49
|
INVALID: 0,
|
|
@@ -1245,6 +1249,10 @@ let startPromise = null;
|
|
|
1245
1249
|
* tests are exactly this case.
|
|
1246
1250
|
*/
|
|
1247
1251
|
function a11yEnabled() {
|
|
1252
|
+
// `createRoot({ desktop: false })` outranks every environment variable,
|
|
1253
|
+
// including the one that forces the climb: it is the embedder saying this
|
|
1254
|
+
// process does not talk to the desktop (src/desktopintegration.js, #417).
|
|
1255
|
+
if (!desktopIntegrationEnabled('a11y')) return false;
|
|
1248
1256
|
const own = process.env.REACT_X11_A11Y;
|
|
1249
1257
|
if (own === '0') return false;
|
|
1250
1258
|
if (own) return true;
|
package/src/appearance.js
CHANGED
|
@@ -57,6 +57,7 @@ import os from 'node:os';
|
|
|
57
57
|
import path from 'node:path';
|
|
58
58
|
|
|
59
59
|
import { sessionBus } from './bus.js';
|
|
60
|
+
import { desktopIntegrationEnabled } from './desktopintegration.js';
|
|
60
61
|
import { PORTAL_NAME, PORTAL_PATH } from './portal.js';
|
|
61
62
|
import { beginXSettings, watchXSettings, xsettings } from './xsettings.js';
|
|
62
63
|
|
|
@@ -238,6 +239,12 @@ function sanitize(saved) {
|
|
|
238
239
|
function load() {
|
|
239
240
|
if (cacheChecked) return;
|
|
240
241
|
cacheChecked = true;
|
|
242
|
+
// `createRoot({ desktop: false })` means this process does not follow the
|
|
243
|
+
// desktop, and that has to include the remembered answer: seeding from the
|
|
244
|
+
// cache would leave an app that opted out drawing in whatever colours this
|
|
245
|
+
// machine happened to be in last time, which is the opposite of the
|
|
246
|
+
// determinism the switch is asked for (#417).
|
|
247
|
+
if (!desktopIntegrationEnabled('appearance')) return;
|
|
241
248
|
const file = cacheFile();
|
|
242
249
|
if (!file) return;
|
|
243
250
|
try {
|
|
@@ -702,6 +709,10 @@ async function runLadder(app) {
|
|
|
702
709
|
*/
|
|
703
710
|
export function systemAppearance(options = {}) {
|
|
704
711
|
if (owner) return Promise.resolve(snapshot);
|
|
712
|
+
// Turned off, so there is nothing to climb and nothing to remember: the
|
|
713
|
+
// defaults, which is a real answer — `'no-preference'` means *use your own*
|
|
714
|
+
// (src/desktopintegration.js).
|
|
715
|
+
if (!desktopIntegrationEnabled('appearance')) return Promise.resolve(NOTHING);
|
|
705
716
|
load();
|
|
706
717
|
if (!probe) {
|
|
707
718
|
// **Failure is not cached**, for the same reason `bus.js` does not cache
|
package/src/atspi.js
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
// resurrected: the slots are cleared and the app simply stops being
|
|
33
33
|
// accessible until restarted, the same contract bus.js documents.
|
|
34
34
|
|
|
35
|
-
import {
|
|
35
|
+
import { loadTransport, resolveAddress } from './bus.js';
|
|
36
36
|
import {
|
|
37
37
|
hooks,
|
|
38
38
|
ATSPI_ROLE,
|
|
@@ -1818,8 +1818,11 @@ async function connected(bus) {
|
|
|
1818
1818
|
*/
|
|
1819
1819
|
async function accessibilityBusAddress(dbus) {
|
|
1820
1820
|
if (process.env.AT_SPI_BUS_ADDRESS) return process.env.AT_SPI_BUS_ADDRESS;
|
|
1821
|
-
|
|
1822
|
-
|
|
1821
|
+
// Resolved, not read: on macOS this is where the session bus address comes
|
|
1822
|
+
// from, and letting `dbus-native` find it for itself would be a blocking
|
|
1823
|
+
// `spawnSync` on the way to the first frame (#417).
|
|
1824
|
+
const busAddress = await resolveAddress('session');
|
|
1825
|
+
if (!busAddress) return null;
|
|
1823
1826
|
let sbus = null;
|
|
1824
1827
|
try {
|
|
1825
1828
|
sbus = dbus.createClient({ busAddress });
|
package/src/bus.js
CHANGED
|
@@ -135,9 +135,8 @@ export async function loadTransport() {
|
|
|
135
135
|
* address is incoherent under sharing. Two callers, two addresses, one
|
|
136
136
|
* socket — which would win? Tests use this same seam.
|
|
137
137
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* the note in `accessibilityBusAddress`.
|
|
138
|
+
* Synchronous, and so it cannot answer on macOS — see `resolveAddress`,
|
|
139
|
+
* which is what every dial goes through.
|
|
141
140
|
*/
|
|
142
141
|
export function addressFor(kind) {
|
|
143
142
|
if (kind === 'system') {
|
|
@@ -149,21 +148,102 @@ export function addressFor(kind) {
|
|
|
149
148
|
if (process.env.DBUS_SESSION_BUS_ADDRESS) {
|
|
150
149
|
return process.env.DBUS_SESSION_BUS_ADDRESS;
|
|
151
150
|
}
|
|
152
|
-
// macOS advertises the session bus through launchd
|
|
153
|
-
//
|
|
151
|
+
// macOS advertises the session bus through launchd rather than through the
|
|
152
|
+
// environment, and asking launchd is a subprocess — see `launchdAddress`.
|
|
154
153
|
if (process.platform === 'darwin') return undefined;
|
|
155
154
|
const runtimeDir = process.env.XDG_RUNTIME_DIR;
|
|
156
155
|
return runtimeDir ? `unix:path=${runtimeDir}/bus` : undefined;
|
|
157
156
|
}
|
|
158
157
|
|
|
158
|
+
/**
|
|
159
|
+
* The in-flight or settled lookup, shared by every dial in the process.
|
|
160
|
+
*
|
|
161
|
+
* **Failure is cached here**, which is the one place in this module where it
|
|
162
|
+
* is — and the exception is deliberate. A session bus can genuinely appear
|
|
163
|
+
* under `$XDG_RUNTIME_DIR` mid-run, so that answer is never remembered; what
|
|
164
|
+
* launchd exports is a *login session* fact, set when the bus is installed
|
|
165
|
+
* and started, which does not happen under a running app. Re-asking would be
|
|
166
|
+
* a fork per feature probe to learn the same "no".
|
|
167
|
+
*/
|
|
168
|
+
let launchd = null;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Ask launchd where the session bus is, **without blocking the event loop**.
|
|
172
|
+
*
|
|
173
|
+
* This is the whole reason `resolveAddress` exists (#417). `dbus-native` will
|
|
174
|
+
* do this lookup itself, from `createStream`, with `spawnSync` — it has to,
|
|
175
|
+
* because its own entry point is synchronous — and a fork+exec of `launchctl`
|
|
176
|
+
* on a cold page cache costs 120–150 ms of *blocked loop*, measured. Landing
|
|
177
|
+
* that inside `createRoot()` stalls the X handshake, the yoga instantiate and
|
|
178
|
+
* the first paint behind a question about the colour scheme.
|
|
179
|
+
*
|
|
180
|
+
* Every caller here is already asynchronous, so the same lookup done with
|
|
181
|
+
* `execFile` costs the same wall clock and none of the loop: the handshake
|
|
182
|
+
* and the layout engine run through it. Handing `dbus-native` the resolved
|
|
183
|
+
* `unix:path=…` is then what keeps it off its own synchronous path.
|
|
184
|
+
*
|
|
185
|
+
* The variable name is D-Bus's, and the fallback to our own environment is
|
|
186
|
+
* `launchdSocketPath`'s: a process launched from a shell that has it already
|
|
187
|
+
* knows the answer.
|
|
188
|
+
*/
|
|
189
|
+
function launchdAddress() {
|
|
190
|
+
if (launchd) return launchd;
|
|
191
|
+
launchd = (async () => {
|
|
192
|
+
const VAR = 'DBUS_LAUNCHD_SESSION_BUS_SOCKET';
|
|
193
|
+
let fromLaunchd = '';
|
|
194
|
+
try {
|
|
195
|
+
const { execFile } = await import('node:child_process');
|
|
196
|
+
fromLaunchd = await new Promise((resolve) => {
|
|
197
|
+
execFile(
|
|
198
|
+
'launchctl',
|
|
199
|
+
['getenv', VAR],
|
|
200
|
+
{ encoding: 'utf8' },
|
|
201
|
+
(err, stdout) => resolve(err ? '' : String(stdout).trim()),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
} catch {
|
|
205
|
+
// no `launchctl` on $PATH, or no child processes to be had at all
|
|
206
|
+
}
|
|
207
|
+
const socket = fromLaunchd || process.env[VAR] || '';
|
|
208
|
+
return socket ? `unix:path=${socket}` : undefined;
|
|
209
|
+
})();
|
|
210
|
+
return launchd;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Where to dial, resolved — the synchronous sources first, and launchd only
|
|
215
|
+
* where they have nothing to say.
|
|
216
|
+
*
|
|
217
|
+
* Not public, but exported for atspi.js, whose one-shot discovery probe
|
|
218
|
+
* dials its own short-lived connection rather than the shared one — see the
|
|
219
|
+
* note in `accessibilityBusAddress`. It matters that it goes through here
|
|
220
|
+
* too: two dials that each let `dbus-native` ask launchd are two blocking
|
|
221
|
+
* forks, and the answer is the same both times.
|
|
222
|
+
*
|
|
223
|
+
* @param {BusKind} kind
|
|
224
|
+
* @returns {Promise<string | undefined>}
|
|
225
|
+
*/
|
|
226
|
+
export async function resolveAddress(kind) {
|
|
227
|
+
const direct = addressFor(kind);
|
|
228
|
+
if (direct !== undefined) return direct;
|
|
229
|
+
if (kind !== 'session' || process.platform !== 'darwin') return undefined;
|
|
230
|
+
return await launchdAddress();
|
|
231
|
+
}
|
|
232
|
+
|
|
159
233
|
function noAddressError(kind) {
|
|
234
|
+
if (kind !== 'session') {
|
|
235
|
+
return new Error(
|
|
236
|
+
'react-x11: no system bus address. $DBUS_SYSTEM_BUS_ADDRESS is unset ' +
|
|
237
|
+
'and there is no default.',
|
|
238
|
+
);
|
|
239
|
+
}
|
|
160
240
|
return new Error(
|
|
161
|
-
|
|
162
|
-
(
|
|
163
|
-
? '
|
|
164
|
-
'
|
|
165
|
-
|
|
166
|
-
|
|
241
|
+
'react-x11: no session bus address. $DBUS_SESSION_BUS_ADDRESS is unset ' +
|
|
242
|
+
(process.platform === 'darwin'
|
|
243
|
+
? 'and launchd has no $DBUS_LAUNCHD_SESSION_BUS_SOCKET either, which ' +
|
|
244
|
+
'is normal on a Mac with no D-Bus installed.'
|
|
245
|
+
: 'and $XDG_RUNTIME_DIR is not set either, which is normal over ssh, ' +
|
|
246
|
+
'under a bare startx and in most containers.'),
|
|
167
247
|
);
|
|
168
248
|
}
|
|
169
249
|
|
|
@@ -216,8 +296,11 @@ async function connect(kind, generation) {
|
|
|
216
296
|
return fail(noTransportError(cause));
|
|
217
297
|
}
|
|
218
298
|
|
|
219
|
-
|
|
220
|
-
|
|
299
|
+
// Resolved rather than read, so `dbus-native` never reaches its own
|
|
300
|
+
// `spawnSync` fallback on macOS (#417) — and so "this Mac has no D-Bus"
|
|
301
|
+
// fails here, before a socket is dialled, instead of inside a constructor.
|
|
302
|
+
const busAddress = await resolveAddress(kind);
|
|
303
|
+
if (!busAddress) {
|
|
221
304
|
return fail(noAddressError(kind));
|
|
222
305
|
}
|
|
223
306
|
|
|
@@ -538,6 +621,10 @@ export function busRefs(kind) {
|
|
|
538
621
|
* exported object survives into the next test.
|
|
539
622
|
*/
|
|
540
623
|
export function _resetBusState() {
|
|
624
|
+
// Including what launchd said: a suite that pins `process.platform` would
|
|
625
|
+
// otherwise inherit the answer — or the absence of one — from a case that
|
|
626
|
+
// ran under a different one.
|
|
627
|
+
launchd = null;
|
|
541
628
|
for (const kind of KINDS) {
|
|
542
629
|
state[kind] = newState(kind, state[kind].generation + 1);
|
|
543
630
|
notify(kind);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Whether this process talks to the desktop at all — the `desktop` option on
|
|
2
|
+
// `createRoot()`, and the one place that decides what it covers.
|
|
3
|
+
//
|
|
4
|
+
// ## What is in the group and why
|
|
5
|
+
//
|
|
6
|
+
// Three things react-x11 turns on for you without being asked, and all three
|
|
7
|
+
// reach the session bus:
|
|
8
|
+
//
|
|
9
|
+
// `appearance` the light/dark, accent, contrast and reduced-motion ladder
|
|
10
|
+
// (src/appearance.js) — the settings portal, then macOS, then
|
|
11
|
+
// XSETTINGS
|
|
12
|
+
// `a11y` the AT-SPI bridge, started from `createRoot()`
|
|
13
|
+
// (src/a11y.js, docs/accessibility.md)
|
|
14
|
+
// `globalMenu` a `MenuBar` handing its menu to the panel instead of drawing
|
|
15
|
+
// it (src/globalmenu.js, docs/globalmenu.md)
|
|
16
|
+
//
|
|
17
|
+
// Each already had an off switch and each of those was an **environment
|
|
18
|
+
// variable** — `REACT_X11_A11Y=0`, `NO_AT_BRIDGE=1`,
|
|
19
|
+
// `REACT_X11_NO_GLOBAL_MENU=1`, or unsetting `DBUS_SESSION_BUS_ADDRESS` for
|
|
20
|
+
// the appearance ladder. That is a seam an app cannot reach for itself: the
|
|
21
|
+
// environment is inherited, so a process that sets one before `createRoot()`
|
|
22
|
+
// has also set it for every child it spawns, and the D-Bus one turns off the
|
|
23
|
+
// portals and the app's own services along with the follower. AGENTS.md asks
|
|
24
|
+
// for the off switch to be somewhere the embedder can actually stand.
|
|
25
|
+
//
|
|
26
|
+
// ## Why the policy is process-wide when the option is per-root
|
|
27
|
+
//
|
|
28
|
+
// Because so is the thing it describes. There is one desktop, one D-Bus
|
|
29
|
+
// identity, and one AT-SPI bridge per process — `startA11y()` says so in its
|
|
30
|
+
// name, and appearance.js says so in its header. A per-root flag over
|
|
31
|
+
// process-wide state would be a seam that reads as finer than it is.
|
|
32
|
+
//
|
|
33
|
+
// So **off wins, and off latches.** A root that says `desktop: false` is a
|
|
34
|
+
// root with a constraint — an embedder that owns the toplevel, a test, a
|
|
35
|
+
// daemon that must not fork — and a second root quietly turning the feature
|
|
36
|
+
// back on for it would be the bug. Turning something back *on* is a thing
|
|
37
|
+
// this module deliberately cannot do.
|
|
38
|
+
//
|
|
39
|
+
// The one honest limit: a feature already started stays started. `startA11y()`
|
|
40
|
+
// is memoised per process, so a second root's `desktop: false` stops the next
|
|
41
|
+
// climb and not the bridge that is already up. Pass it on the first root.
|
|
42
|
+
|
|
43
|
+
/** @typedef {'appearance'|'a11y'|'globalMenu'} DesktopFeature */
|
|
44
|
+
|
|
45
|
+
/** @type {DesktopFeature[]} */
|
|
46
|
+
const FEATURES = ['appearance', 'a11y', 'globalMenu'];
|
|
47
|
+
|
|
48
|
+
/** What has been turned off, for the life of the process. */
|
|
49
|
+
const off = new Set();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Apply a root's `desktop` option. Not public — `createRoot({ desktop })` is
|
|
53
|
+
* the public shape.
|
|
54
|
+
*
|
|
55
|
+
* `undefined` is the default and means every feature stays on; `false` turns
|
|
56
|
+
* all of them off; an object names them one at a time, and a key left out is
|
|
57
|
+
* left alone.
|
|
58
|
+
*
|
|
59
|
+
* @param {boolean | Partial<Record<DesktopFeature, boolean>> | undefined} desktop
|
|
60
|
+
*/
|
|
61
|
+
export function setDesktopIntegration(desktop) {
|
|
62
|
+
if (desktop === undefined || desktop === true) return;
|
|
63
|
+
if (desktop === false) {
|
|
64
|
+
for (const feature of FEATURES) off.add(feature);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (typeof desktop !== 'object') {
|
|
68
|
+
throw new TypeError(
|
|
69
|
+
'react-x11: createRoot({ desktop }) takes false or an object of ' +
|
|
70
|
+
`${FEATURES.map((f) => `${f}: false`).join(', ')} — got ` +
|
|
71
|
+
`${JSON.stringify(desktop)}.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
for (const [feature, on] of Object.entries(desktop)) {
|
|
75
|
+
if (!FEATURES.includes(/** @type {DesktopFeature} */ (feature))) {
|
|
76
|
+
throw new TypeError(
|
|
77
|
+
`react-x11: createRoot({ desktop: { ${feature} } }) — no such ` +
|
|
78
|
+
`desktop integration. Expected ${FEATURES.join(', ')}.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (on === false) off.add(feature);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether a feature may run. Every one of the three asks this first, ahead of
|
|
87
|
+
* its own environment variable, so `desktop: false` is the outermost answer.
|
|
88
|
+
*
|
|
89
|
+
* @param {DesktopFeature} feature
|
|
90
|
+
*/
|
|
91
|
+
export function desktopIntegrationEnabled(feature) {
|
|
92
|
+
return !off.has(feature);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Test seam, not public: forget the latch. */
|
|
96
|
+
export function _resetDesktopIntegration() {
|
|
97
|
+
off.clear();
|
|
98
|
+
}
|
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 {
|
package/src/globalmenu.js
CHANGED
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
import { useEffect, useRef, useState } from 'react';
|
|
58
58
|
|
|
59
59
|
import { loadTransport, sessionBus } from './bus.js';
|
|
60
|
+
import { desktopIntegrationEnabled } from './desktopintegration.js';
|
|
60
61
|
import {
|
|
61
62
|
DBUSMENU_IFACE,
|
|
62
63
|
PROPERTY_TYPES,
|
|
@@ -126,6 +127,10 @@ const menuPathFor = (xid) => `/com/react_x11/menus/${xid}`;
|
|
|
126
127
|
* it is the one that works without touching application code.
|
|
127
128
|
*/
|
|
128
129
|
function globalMenuEnabled() {
|
|
130
|
+
// `createRoot({ desktop: false })` first: handing the menu to a panel is
|
|
131
|
+
// one of the three things core turns on for you, and that is the switch
|
|
132
|
+
// that turns the group off (src/desktopintegration.js, #417).
|
|
133
|
+
if (!desktopIntegrationEnabled('globalMenu')) return false;
|
|
129
134
|
const flag = process.env.REACT_X11_NO_GLOBAL_MENU;
|
|
130
135
|
return !flag || flag === '0';
|
|
131
136
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -298,6 +298,43 @@ export interface RootOptions {
|
|
|
298
298
|
* and coming back is the user's own Tab.
|
|
299
299
|
*/
|
|
300
300
|
restoreFocusOnReveal?: boolean;
|
|
301
|
+
/**
|
|
302
|
+
* Whether this process talks to the desktop over D-Bus. On by default, and
|
|
303
|
+
* `false` turns off all three things react-x11 starts for you (#417,
|
|
304
|
+
* docs/desktop.md):
|
|
305
|
+
*
|
|
306
|
+
* | | |
|
|
307
|
+
* | --- | --- |
|
|
308
|
+
* | `appearance` | following the desktop's light/dark, accent, contrast and reduced motion (docs/appearance.md) |
|
|
309
|
+
* | `a11y` | the AT-SPI bridge that makes the app reachable by a screen reader (docs/accessibility.md) |
|
|
310
|
+
* | `globalMenu` | a `MenuBar` handing its menu to the panel instead of drawing it (docs/globalmenu.md) |
|
|
311
|
+
*
|
|
312
|
+
* ```js
|
|
313
|
+
* await createRoot({ desktop: false }); // none of it
|
|
314
|
+
* await createRoot({ desktop: { appearance: false } }); // just that one
|
|
315
|
+
* ```
|
|
316
|
+
*
|
|
317
|
+
* For an embedder that owns those integrations itself, a kiosk or daemon
|
|
318
|
+
* that must not fork a subprocess to find the bus, and a test that wants
|
|
319
|
+
* one answer on every machine. With all three off nothing dials the
|
|
320
|
+
* session bus at startup.
|
|
321
|
+
*
|
|
322
|
+
* **Off is process-wide and it latches.** There is one desktop and one
|
|
323
|
+
* D-Bus identity per process, so a second root cannot turn back on what
|
|
324
|
+
* another turned off — and a feature already started stays started, so
|
|
325
|
+
* pass this on the first root.
|
|
326
|
+
*/
|
|
327
|
+
desktop?: boolean | DesktopIntegrationOptions;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Which desktop integrations run. See {@link CreateRootOptions.desktop}. */
|
|
331
|
+
export interface DesktopIntegrationOptions {
|
|
332
|
+
/** Follow the desktop's light/dark, accent, contrast and reduced motion. */
|
|
333
|
+
appearance?: boolean;
|
|
334
|
+
/** The AT-SPI bridge — whether a screen reader can see this app. */
|
|
335
|
+
a11y?: boolean;
|
|
336
|
+
/** Whether a `MenuBar` hands its menu to the panel. */
|
|
337
|
+
globalMenu?: boolean;
|
|
301
338
|
}
|
|
302
339
|
|
|
303
340
|
export interface ComposeOptions {
|
package/src/nodes.js
CHANGED
|
@@ -109,7 +109,12 @@ import {
|
|
|
109
109
|
windowOrigin,
|
|
110
110
|
} from './anchor.js';
|
|
111
111
|
import { baseTheme } from './palette.js';
|
|
112
|
-
import {
|
|
112
|
+
import {
|
|
113
|
+
callHandler,
|
|
114
|
+
ownerName,
|
|
115
|
+
reportStyleError,
|
|
116
|
+
STRICT_TOKENS,
|
|
117
|
+
} from './errors.js';
|
|
113
118
|
import {
|
|
114
119
|
hooks as a11yHooks,
|
|
115
120
|
isFocusable as a11yFocusable,
|
|
@@ -1218,6 +1223,19 @@ function contentSpan(node, axis, intrinsic, out) {
|
|
|
1218
1223
|
* one pass by `contentSpan`. `floored` collects what was written so the next
|
|
1219
1224
|
* measurement can take it back off — a floor left in place would be read
|
|
1220
1225
|
* back as content that cannot give, and could then only ratchet upwards.
|
|
1226
|
+
*
|
|
1227
|
+
* A floor is written **unrounded**, and the measurement it came from ran
|
|
1228
|
+
* with the pixel grid off (`measuringExactly`) for the reason given there:
|
|
1229
|
+
* rounding a floor grows the tree a pixel per nesting level. What that
|
|
1230
|
+
* leaves is a sharp edge in yoga worth knowing about before writing a
|
|
1231
|
+
* measure function. A line whose items are all held at their floors is one
|
|
1232
|
+
* yoga freezes item by item, subtracting each item's shrink factor from the
|
|
1233
|
+
* line's total as it goes; the total only cancels to zero if the sizes add
|
|
1234
|
+
* up exactly in binary. Three items of, say, 239.28 in a column that
|
|
1235
|
+
* overflows do not, and yoga divides the overflow by the rounding residue
|
|
1236
|
+
* instead of skipping the division — the items come back a billion pixels
|
|
1237
|
+
* tall (issue #411). Whole pixels cancel exactly, which is why the text
|
|
1238
|
+
* measures here answer in them (`TextNode._trim`).
|
|
1221
1239
|
*/
|
|
1222
1240
|
function writeContentFloors(node, axis, mins, floored) {
|
|
1223
1241
|
const axisIsMain = mainAxisOf(node) === axis;
|
|
@@ -1544,6 +1562,11 @@ export class Node {
|
|
|
1544
1562
|
// subtree's hit reach, invalidated through _clearHitBounds()
|
|
1545
1563
|
this._paintOrderCache = null;
|
|
1546
1564
|
this._hitBoundsCache = null;
|
|
1565
|
+
// a `$token` the theme does not define, held for `commitMount` to throw
|
|
1566
|
+
// on this node's own fiber — see `_tokenProblem`. Strict mode only.
|
|
1567
|
+
// `null` is "commitMount is still to come", `false` is "it has been and
|
|
1568
|
+
// gone", and an Error is one waiting for it
|
|
1569
|
+
this._tokenError = null;
|
|
1547
1570
|
this._syncStyle(props);
|
|
1548
1571
|
this.yoga = yoga ? createLayoutNode() : null;
|
|
1549
1572
|
if (this.yoga) {
|
|
@@ -1575,7 +1598,7 @@ export class Node {
|
|
|
1575
1598
|
* them. `baseStyle` is the flattened `style` prop; `style` is that with
|
|
1576
1599
|
* the active state blocks overlaid.
|
|
1577
1600
|
*/
|
|
1578
|
-
_syncStyle(props) {
|
|
1601
|
+
_syncStyle(props, mounting = false) {
|
|
1579
1602
|
if (DEV && this.stylable) {
|
|
1580
1603
|
assertNoFlatStyleProps(props, this.kind, this.semanticNames);
|
|
1581
1604
|
validateStyle(flattenStyle(props.style), `<${this.kind} style>`);
|
|
@@ -1584,12 +1607,16 @@ export class Node {
|
|
|
1584
1607
|
this._usesTokens = this.stylable && styleUsesTokens(this._baseStyle);
|
|
1585
1608
|
if (this._usesTokens) {
|
|
1586
1609
|
const theme = this.theme;
|
|
1610
|
+
const strict = this.placed;
|
|
1611
|
+
const problems = strict ? [] : null;
|
|
1587
1612
|
this._baseStyle = resolveTokens(
|
|
1588
1613
|
this._baseStyle,
|
|
1589
1614
|
theme,
|
|
1590
1615
|
`<${this.kind} style>`,
|
|
1591
|
-
|
|
1616
|
+
strict,
|
|
1617
|
+
problems,
|
|
1592
1618
|
);
|
|
1619
|
+
if (problems?.length) this._tokenProblem(problems, mounting);
|
|
1593
1620
|
}
|
|
1594
1621
|
// `disabled` is a prop, not something the pointer does, so it is read
|
|
1595
1622
|
// straight off props rather than driven by the event manager
|
|
@@ -2228,6 +2255,46 @@ export class Node {
|
|
|
2228
2255
|
return owner.isPopup ? owner.parent != null : true;
|
|
2229
2256
|
}
|
|
2230
2257
|
|
|
2258
|
+
/**
|
|
2259
|
+
* A `$token` this node's completed ancestry does not define.
|
|
2260
|
+
*
|
|
2261
|
+
* The default is `reportStyleError`: say so loudly, set `process.exitCode`,
|
|
2262
|
+
* and keep the property dropped. `REACT_X11_STRICT_TOKENS=1` makes it fatal
|
|
2263
|
+
* again, and then *where* the throw lands is the whole question — an error
|
|
2264
|
+
* boundary only catches what React invoked, on the fiber React thinks it
|
|
2265
|
+
* is working on.
|
|
2266
|
+
*
|
|
2267
|
+
* `mounting` is the attach walk, which runs inside `appendInitialChild`
|
|
2268
|
+
* while React is completing the nearest host *ancestor* — the `<window>`,
|
|
2269
|
+
* for a whole tree rendered at once. A throw there is attributed to the
|
|
2270
|
+
* window and sails past every boundary the app wrote inside it, which is
|
|
2271
|
+
* the bug this deferral exists for (#420). Stashed instead, and thrown
|
|
2272
|
+
* from `commitMount` on this node's own fiber, where the walk up finds a
|
|
2273
|
+
* boundary at any depth.
|
|
2274
|
+
*
|
|
2275
|
+
* Every other caller already has the right fiber (`commitUpdate`) or has
|
|
2276
|
+
* no React on the stack at all (`appearanceChanged`, from an X event) —
|
|
2277
|
+
* for those, throwing here is both the earliest and the only option, and
|
|
2278
|
+
* the second is the crash strict mode asked for.
|
|
2279
|
+
*
|
|
2280
|
+
* `commitMount` happens once per instance, so a node re-attached after it
|
|
2281
|
+
* has been and gone has nothing left to defer *to*; stashing there would
|
|
2282
|
+
* swallow the error instead of raising it late. Those throw at once, like
|
|
2283
|
+
* the keyed reorder they resemble.
|
|
2284
|
+
*/
|
|
2285
|
+
_tokenProblem(problems, mounting) {
|
|
2286
|
+
if (!STRICT_TOKENS) {
|
|
2287
|
+
// every one of them: two misspellings in a style are two things to
|
|
2288
|
+
// fix, and a report that named only the first would send someone back
|
|
2289
|
+
// for a second run to find the second
|
|
2290
|
+
for (const message of problems) reportStyleError(this, message);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
const error = new Error(problems[0]);
|
|
2294
|
+
if (mounting && this._tokenError === null) this._tokenError = error;
|
|
2295
|
+
else throw error;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2231
2298
|
/** The owning window resized: re-resolve, since a query block may now
|
|
2232
2299
|
* match that did not, or the other way round. */
|
|
2233
2300
|
_sizeQueriesChanged() {
|
|
@@ -2274,7 +2341,7 @@ export class Node {
|
|
|
2274
2341
|
if (this.isWindow) this._syncWindowBackground();
|
|
2275
2342
|
if (this._usesTokens) {
|
|
2276
2343
|
const before = this.style;
|
|
2277
|
-
this._syncStyle(this.props);
|
|
2344
|
+
this._syncStyle(this.props, mounting);
|
|
2278
2345
|
// a token change reaches the node without React re-rendering it, so
|
|
2279
2346
|
// the invalidation a commit would have done has to happen here too
|
|
2280
2347
|
if (localTextStyleChanged(this.style, before)) {
|
|
@@ -4587,7 +4654,12 @@ export class TextNode extends Node {
|
|
|
4587
4654
|
|
|
4588
4655
|
/** Height for a width: the paragraph shaped into whatever is on offer.
|
|
4589
4656
|
* The offer is `Infinity` when nothing bounds it, which is also what
|
|
4590
|
-
* `textWrap: 'nowrap'` asks for, so neither needs a mode.
|
|
4657
|
+
* `textWrap: 'nowrap'` asks for, so neither needs a mode.
|
|
4658
|
+
*
|
|
4659
|
+
* Both answers are **whole pixels**, the trimmed one included — see
|
|
4660
|
+
* `_trim` for why the rounding is not cosmetic. The glyphs are placed
|
|
4661
|
+
* from the unrounded trim (`_placedLayout`), so what the rounding moves
|
|
4662
|
+
* is the bottom edge of the box, by less than half a pixel. */
|
|
4591
4663
|
measureContent({ width }) {
|
|
4592
4664
|
const layout = this._layoutFor(this._wrapWidth(width));
|
|
4593
4665
|
if (!layout) return { width: 0, height: 0 };
|
|
@@ -4596,7 +4668,9 @@ export class TextNode extends Node {
|
|
|
4596
4668
|
width: Math.ceil(layout.width),
|
|
4597
4669
|
height: Math.max(
|
|
4598
4670
|
0,
|
|
4599
|
-
|
|
4671
|
+
trim
|
|
4672
|
+
? Math.round(Math.ceil(layout.height) - (trim.top + trim.bottom))
|
|
4673
|
+
: Math.ceil(layout.height),
|
|
4600
4674
|
),
|
|
4601
4675
|
};
|
|
4602
4676
|
}
|
|
@@ -4852,6 +4926,21 @@ export class TextNode extends Node {
|
|
|
4852
4926
|
* Measured in the coordinates the layout is **drawn** in, not the ones it
|
|
4853
4927
|
* reports: `halfLeading` shifts it, and deriving the baseline from the
|
|
4854
4928
|
* metrics again would silently disagree the day that shift changes.
|
|
4929
|
+
*
|
|
4930
|
+
* The amounts are fractions of a pixel and stay that way — the glyphs are
|
|
4931
|
+
* placed from them (`_placedLayout`). What must not stay fractional is the
|
|
4932
|
+
* **box** they leave behind, which is why `measureContent` rounds the
|
|
4933
|
+
* height it reports and this does not (issue #411).
|
|
4934
|
+
*
|
|
4935
|
+
* A trimmed label measures to the cap band, and a cap height is a fraction
|
|
4936
|
+
* of the em — so before the rounding, a column of trimmed titles handed
|
|
4937
|
+
* yoga three or four flex items whose main size had a fraction in it and
|
|
4938
|
+
* whose content floors (#249) were that same fraction. Yoga freezes a line
|
|
4939
|
+
* like that item by item and divides the overflow by a total shrink factor
|
|
4940
|
+
* that should have cancelled to zero; a fraction that is not exact in
|
|
4941
|
+
* binary leaves a rounding residue there instead, and dividing by it laid
|
|
4942
|
+
* the section titles of `examples/configurator` out 5.6 billion pixels
|
|
4943
|
+
* tall. See `writeContentFloors`, which is the other end of it.
|
|
4855
4944
|
*/
|
|
4856
4945
|
_trim(layout) {
|
|
4857
4946
|
if (this.style.textBoxTrim !== 'cap-alphabetic') return null;
|
|
@@ -6645,8 +6734,8 @@ export class TextInputNode extends Node {
|
|
|
6645
6734
|
/** A preferred width, capped to whatever is on offer — `Infinity` when
|
|
6646
6735
|
* nothing is, which is what makes the `Math.min` the whole rule. */
|
|
6647
6736
|
measureContent({ width }) {
|
|
6648
|
-
//
|
|
6649
|
-
//
|
|
6737
|
+
// `_capBand` rounds, and a trimmed `<text>` rounds the same band the
|
|
6738
|
+
// same way — rounding one of them and not the other is a pixel of
|
|
6650
6739
|
// difference between a field and the button beside it.
|
|
6651
6740
|
return { width: Math.min(150, width), height: this._capBand() };
|
|
6652
6741
|
}
|
|
@@ -6896,12 +6985,16 @@ export class TextInputNode extends Node {
|
|
|
6896
6985
|
};
|
|
6897
6986
|
}
|
|
6898
6987
|
|
|
6988
|
+
/** Whole pixels either way: a field's height is a flex item's main size,
|
|
6989
|
+
* and a fractional one costs the tree its content floors (see
|
|
6990
|
+
* `TextNode._trim`, issue #411). The face with no `capHeight` to round is
|
|
6991
|
+
* the one that reaches the fallback. */
|
|
6899
6992
|
_capBand() {
|
|
6900
6993
|
const style = this.resolvedTextStyle();
|
|
6901
6994
|
const cap = this.app?.fonts
|
|
6902
6995
|
?.match?.(style.family, { weight: style.weight, style: style.style })
|
|
6903
6996
|
?.metrics?.(style.size)?.capHeight;
|
|
6904
|
-
return
|
|
6997
|
+
return Math.round(cap || this._lineHeight());
|
|
6905
6998
|
}
|
|
6906
6999
|
|
|
6907
7000
|
/** Shaped layout of the current value, cached per (value, style,
|
package/src/style.d.ts
CHANGED
|
@@ -43,12 +43,15 @@ export function tokenNames(
|
|
|
43
43
|
style: StyleProperties,
|
|
44
44
|
out?: Set<string>,
|
|
45
45
|
): Set<string>;
|
|
46
|
-
/** Replace `$token` references with values from the theme.
|
|
46
|
+
/** Replace `$token` references with values from the theme. A token the theme
|
|
47
|
+
* does not define is dropped either way; with `strict`, the message naming
|
|
48
|
+
* it is pushed onto `problems` for the caller to report or throw. */
|
|
47
49
|
export function resolveTokens(
|
|
48
50
|
style: StyleProperties,
|
|
49
51
|
theme: Record<string, unknown> | null | undefined,
|
|
50
52
|
where?: string,
|
|
51
53
|
strict?: boolean,
|
|
54
|
+
problems?: string[] | null,
|
|
52
55
|
): StyleProperties;
|
|
53
56
|
|
|
54
57
|
export function styleHasSizeQueries(style: StyleProperties): boolean;
|
package/src/styles.js
CHANGED
|
@@ -1206,16 +1206,37 @@ export function stripTokens(style) {
|
|
|
1206
1206
|
* `strict` says the node's ancestry is complete, so a token that does not
|
|
1207
1207
|
* resolve is a mistake. While a subtree is still being built its nodes can
|
|
1208
1208
|
* see only part of their ancestry — the theme two levels up does not exist
|
|
1209
|
-
* for them yet — so resolution there is provisional
|
|
1210
|
-
*
|
|
1209
|
+
* for them yet — so resolution there is provisional.
|
|
1210
|
+
*
|
|
1211
|
+
* Both cases drop the property, because a value is either resolved or absent
|
|
1212
|
+
* and `'$textMuted1'` is not a colour. What `strict` changes is whether
|
|
1213
|
+
* anyone hears about it: mistakes are pushed onto `problems` and the caller
|
|
1214
|
+
* decides what one costs. Resolving itself never throws — it runs from a
|
|
1215
|
+
* commit and from an X event alike, and only the caller knows whether React
|
|
1216
|
+
* is on the stack to route a throw to a boundary (src/nodes.js).
|
|
1217
|
+
*
|
|
1218
|
+
* A cache hit replays the problems it recorded, so the second node to wear a
|
|
1219
|
+
* misspelled shared style is reported like the first.
|
|
1211
1220
|
*/
|
|
1212
|
-
export function resolveTokens(
|
|
1221
|
+
export function resolveTokens(
|
|
1222
|
+
style,
|
|
1223
|
+
theme,
|
|
1224
|
+
where = 'style',
|
|
1225
|
+
strict = true,
|
|
1226
|
+
problems = null,
|
|
1227
|
+
) {
|
|
1213
1228
|
if (!theme) return stripTokens(style);
|
|
1214
1229
|
let byTheme = strict ? resolvedCache.get(style) : null;
|
|
1215
1230
|
if (strict && !byTheme) resolvedCache.set(style, (byTheme = new WeakMap()));
|
|
1216
1231
|
const hit = byTheme?.get(theme);
|
|
1217
|
-
if (hit)
|
|
1232
|
+
if (hit) {
|
|
1233
|
+
if (problems && hit.problems) problems.push(...hit.problems);
|
|
1234
|
+
return hit.out;
|
|
1235
|
+
}
|
|
1218
1236
|
|
|
1237
|
+
// collected here rather than pushed straight to `problems` so the cache
|
|
1238
|
+
// entry can keep them: the caller that misses is not the only one to hear
|
|
1239
|
+
const found = strict ? [] : null;
|
|
1219
1240
|
const out = {};
|
|
1220
1241
|
for (const key of Object.keys(style)) {
|
|
1221
1242
|
const v = style[key];
|
|
@@ -1225,11 +1246,7 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
|
|
|
1225
1246
|
out[key] = theme[name];
|
|
1226
1247
|
continue;
|
|
1227
1248
|
}
|
|
1228
|
-
if (
|
|
1229
|
-
throw new Error(
|
|
1230
|
-
`react-x11: unknown theme token "${v}" in ${where} ` +
|
|
1231
|
-
`(theme has ${Object.keys(theme).join(', ') || 'nothing'})`,
|
|
1232
|
-
);
|
|
1249
|
+
if (strict) found.push(unknownToken(v, theme, where));
|
|
1233
1250
|
} else if (mentionsToken(v)) {
|
|
1234
1251
|
let unknown = null;
|
|
1235
1252
|
const substituted = v.replace(TOKEN_IN_VALUE, (token) => {
|
|
@@ -1245,13 +1262,10 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
|
|
|
1245
1262
|
// frame instead of at the style.
|
|
1246
1263
|
if (!unknown) out[key] = substituted;
|
|
1247
1264
|
else if (strict) {
|
|
1248
|
-
|
|
1249
|
-
`react-x11: unknown theme token "${unknown}" in ${where} ${key} ` +
|
|
1250
|
-
`(theme has ${Object.keys(theme).join(', ') || 'nothing'})`,
|
|
1251
|
-
);
|
|
1265
|
+
found.push(unknownToken(unknown, theme, `${where} ${key}`));
|
|
1252
1266
|
}
|
|
1253
1267
|
} else if (key.charCodeAt(0) === 58 && v) {
|
|
1254
|
-
out[key] = resolveTokens(v, theme, `${where} ${key}`, strict);
|
|
1268
|
+
out[key] = resolveTokens(v, theme, `${where} ${key}`, strict, found);
|
|
1255
1269
|
} else if (key === 'animation' && v && typeof v === 'object') {
|
|
1256
1270
|
const loops = {};
|
|
1257
1271
|
let incomplete = false;
|
|
@@ -1266,11 +1280,11 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
|
|
|
1266
1280
|
theme,
|
|
1267
1281
|
`${where} animation ${prop}`,
|
|
1268
1282
|
strict,
|
|
1283
|
+
found,
|
|
1269
1284
|
);
|
|
1270
|
-
// A
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
// the ancestry to complete rather than throwing at a half of one.
|
|
1285
|
+
// A loop with one end missing is not a shorter loop, so a
|
|
1286
|
+
// declaration that lost a value is dropped whole rather than run
|
|
1287
|
+
// between a colour and nothing.
|
|
1274
1288
|
if (Object.keys(resolved).length !== Object.keys(entry).length) {
|
|
1275
1289
|
incomplete = true;
|
|
1276
1290
|
}
|
|
@@ -1281,10 +1295,20 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
|
|
|
1281
1295
|
out[key] = v;
|
|
1282
1296
|
}
|
|
1283
1297
|
}
|
|
1284
|
-
|
|
1298
|
+
if (found?.length && problems) problems.push(...found);
|
|
1299
|
+
byTheme?.set(theme, { out, problems: found?.length ? found : null });
|
|
1285
1300
|
return out;
|
|
1286
1301
|
}
|
|
1287
1302
|
|
|
1303
|
+
/** The one message, written once: what was named, and what the palette in
|
|
1304
|
+
* force actually has — listing the alternatives is most of the fix. */
|
|
1305
|
+
function unknownToken(token, theme, where) {
|
|
1306
|
+
return (
|
|
1307
|
+
`react-x11: unknown theme token "${token}" in ${where} ` +
|
|
1308
|
+
`(theme has ${Object.keys(theme).join(', ') || 'nothing'})`
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1288
1312
|
export { validateStyle };
|
|
1289
1313
|
|
|
1290
1314
|
/**
|