staysfixed 0.4.0 → 0.6.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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Letting you watch, without taking your screen.
3
+ *
4
+ * The owner asked for something more precise than "run it invisibly", and he was right
5
+ * to. He wants to SEE it work — the app opening, the panel beside it, each check ticking
6
+ * green — because watching it is most of how you come to trust it. What he does not want
7
+ * is what it did to him tonight:
8
+ *
9
+ * "if i click something and bring [my app] on the first layer of the screen and i am
10
+ * working on something, after my click it will not keep bringing it up. it will just
11
+ * keep it back side and keep working."
12
+ *
13
+ * So the rule is not "stay hidden". It is: **come up once, then never come up again.**
14
+ *
15
+ * That distinction is the whole of this file. An app the tool opens is allowed to appear —
16
+ * it should, the first time, so a person can see what is happening. From the moment the
17
+ * person picks something else, whatever the tool launched loses the argument for good.
18
+ *
19
+ * ## Why a guard rather than a flag
20
+ *
21
+ * There is no flag for this. An Electron app calls `app.focus()` and `win.show()` from its
22
+ * own main process during startup, when a window opens, when a dialog appears; a simulator
23
+ * activates when it boots; a browser activates when a new window is created. None of that
24
+ * goes through us, so none of it can be forbidden at launch time. The only thing that
25
+ * actually works is to watch who is in front and put the person's app back when something
26
+ * of ours pushes in front of it.
27
+ *
28
+ * ## Why polling is the right answer here, unusually
29
+ *
30
+ * The standing rule in this codebase is events over polling. macOS does publish an
31
+ * activation notification, but reading it needs a process inside the window server session
32
+ * with an event loop — a small native helper or a persistent AppleScript, both of which are
33
+ * a thing to install and a thing to leave running on his machine. A twelve-line
34
+ * `osascript` every 400ms costs about a millisecond of CPU and installs nothing. The rule
35
+ * exists to stop wasteful polling; this is the case it does not cover.
36
+ */
37
+
38
+ import { execFile } from 'node:child_process';
39
+ import { promisify } from 'node:util';
40
+ import { detail } from '../../core/log.js';
41
+
42
+ const run = promisify(execFile);
43
+
44
+ /** How often to look. Fast enough that a stolen screen is given back before it is annoying. */
45
+ const LOOK_EVERY_MS = 400;
46
+
47
+ /**
48
+ * How long to leave the tool's window alone at the start.
49
+ *
50
+ * It has just been opened deliberately and a person is probably looking at it. Snatching
51
+ * focus away in the same instant would be its own kind of rude, and would also fight the
52
+ * launch itself while the app is still deciding which of its windows is in front.
53
+ */
54
+ const GRACE_MS = 2500;
55
+
56
+ /** @typedef {{name: string}} Frontmost */
57
+
58
+ /**
59
+ * Who is in front right now, by application name.
60
+ *
61
+ * Returns null rather than throwing on any failure — no window server, no Apple Events
62
+ * permission, a headless machine, a locked screen. Every one of those means "there is no
63
+ * screen to take", which is not an error and must never fail a check.
64
+ *
65
+ * @returns {Promise<string|null>}
66
+ */
67
+ export async function frontmostApp() {
68
+ if (process.platform !== 'darwin') return null;
69
+ try {
70
+ const { stdout } = await run(
71
+ 'osascript',
72
+ ['-e', 'tell application "System Events" to get name of first application process whose frontmost is true'],
73
+ { timeout: 3000 },
74
+ );
75
+ const name = stdout.trim();
76
+ return name.length > 0 ? name : null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Bring one application back to the front.
84
+ *
85
+ * @param {string} name
86
+ * @returns {Promise<boolean>} whether it worked
87
+ */
88
+ export async function bringForward(name) {
89
+ if (process.platform !== 'darwin' || !name) return false;
90
+ try {
91
+ await run(
92
+ 'osascript',
93
+ [
94
+ '-e',
95
+ `tell application "System Events" to set frontmost of first application process whose name is ${JSON.stringify(name)} to true`,
96
+ ],
97
+ { timeout: 3000 },
98
+ );
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * @typedef {object} ScreenGuard
107
+ * @property {(name: string) => void} claim Tell the guard an application belongs to the tool.
108
+ * @property {() => Promise<void>} release Stop guarding. Always safe to call twice.
109
+ * @property {() => GuardReport} report What it did, for the run summary.
110
+ */
111
+
112
+ /**
113
+ * @typedef {object} GuardReport
114
+ * @property {number} handedBack How many times the screen was taken and given back.
115
+ * @property {string|null} yours The application the guard believes is yours.
116
+ * @property {string[]} ours Everything the tool opened.
117
+ * @property {boolean} watching False when there is no screen to guard.
118
+ */
119
+
120
+ /**
121
+ * Watch who is in front, and give the screen back when something of ours takes it.
122
+ *
123
+ * The bookkeeping is deliberately simple, because a clever version of this would guess
124
+ * wrong and fight the person for their own screen:
125
+ *
126
+ * - Anything the tool launches is `ours`, named as the tool launches it.
127
+ * - Anything else that is frontmost is *yours*, and the guard remembers the last one. That
128
+ * is how it learns what to put back — by watching what you actually chose, never by
129
+ * being told.
130
+ * - When one of ours is in front and you have chosen something since, yours goes back.
131
+ * - When one of ours is in front and you have chosen nothing yet, it is left alone. That
132
+ * first appearance is the point: it is how you see what is happening.
133
+ *
134
+ * @param {{claims?: string[], everyMs?: number, graceMs?: number}} [opts]
135
+ * @returns {ScreenGuard}
136
+ */
137
+ export function guardTheScreen(opts = {}) {
138
+ const everyMs = opts.everyMs ?? LOOK_EVERY_MS;
139
+ const graceMs = opts.graceMs ?? GRACE_MS;
140
+
141
+ /** @type {Set<string>} everything the tool opened */
142
+ const ours = new Set(opts.claims ?? []);
143
+ /** @type {string|null} the last application the person chose for themselves */
144
+ let yours = null;
145
+ let handedBack = 0;
146
+ let stopped = process.platform !== 'darwin';
147
+ /** @type {ReturnType<typeof setTimeout>|null} */
148
+ let timer = null;
149
+ const startedAt = Date.now();
150
+
151
+ /** @param {string} name */
152
+ const claim = (name) => {
153
+ if (name) ours.add(name);
154
+ };
155
+
156
+ const isOurs = (/** @type {string} */ name) => {
157
+ for (const one of ours) {
158
+ // A launched application is often reported under a slightly different name than the
159
+ // path it was started from — "Terminal Deck" for a binary called "Terminal Deck", but
160
+ // "Electron" for a development build, and "Simulator" for a simulator boot. Matching
161
+ // loosely in both directions is what makes this work without a table of special cases.
162
+ if (name === one || name.includes(one) || one.includes(name)) return true;
163
+ }
164
+ return false;
165
+ };
166
+
167
+ const look = async () => {
168
+ if (stopped) return;
169
+ const front = await frontmostApp();
170
+ if (front) {
171
+ if (!isOurs(front)) {
172
+ // The person chose this. It is now what "yours" means.
173
+ yours = front;
174
+ } else if (yours && Date.now() - startedAt > graceMs) {
175
+ // Something of ours is in front, and there is somewhere to put you back.
176
+ const ok = await bringForward(yours);
177
+ if (ok) {
178
+ handedBack += 1;
179
+ detail(`the screen was taken by ${front}; gave it back to ${yours}`);
180
+ }
181
+ }
182
+ }
183
+ if (!stopped) timer = setTimeout(look, everyMs);
184
+ };
185
+
186
+ if (!stopped) timer = setTimeout(look, everyMs);
187
+
188
+ return {
189
+ claim,
190
+ async release() {
191
+ stopped = true;
192
+ if (timer) clearTimeout(timer);
193
+ timer = null;
194
+ },
195
+ report() {
196
+ return { handedBack, yours, ours: [...ours], watching: !stopped };
197
+ },
198
+ };
199
+ }
200
+
201
+ /**
202
+ * One sentence for the summary, or nothing when there is nothing worth saying.
203
+ *
204
+ * A person who was not interrupted should not be told about the machinery that did not
205
+ * interrupt them. This only speaks when it actually did something.
206
+ *
207
+ * @param {GuardReport} report
208
+ * @returns {string|null}
209
+ */
210
+ export function describeGuard(report) {
211
+ if (!report || report.handedBack === 0) return null;
212
+ const times = report.handedBack === 1 ? 'once' : `${report.handedBack} times`;
213
+ const back = report.yours ? ` to ${report.yours}` : '';
214
+ return `Something the check opened came to the front ${times} and the screen was handed straight back${back}.`;
215
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * The watch window, from the check's point of view.
3
+ *
4
+ * A check describes itself into an event stream and carries on. This subscribes to that
5
+ * stream, opens a window beside whatever is being checked, and forwards everything to it. It
6
+ * has no other effect on the check: it takes nothing away from it, it holds nothing up, and
7
+ * when the window cannot open the check does not notice.
8
+ *
9
+ * THREE THINGS HERE ARE NOT OBVIOUS, and all three are the owner's one requirement said in
10
+ * different places:
11
+ *
12
+ * "That window should come up... But once we minimise it, it should keep working headless
13
+ * in the background. Not invisible. It should not keep bringing itself to the front."
14
+ *
15
+ * ONE. The window is opened WITHOUT being waited for. `attachWatcher` hands back a watcher
16
+ * immediately and the browser starts behind it, so the check begins in the same millisecond it
17
+ * would have begun with no window at all. That is safe only because the event stream hands a
18
+ * late listener everything that already happened, in order — so a window that took two seconds
19
+ * to open still draws the two seconds it missed.
20
+ *
21
+ * TWO. The ENGINE works everything out and the PANEL only draws. Every sentence and every
22
+ * number a person reads is made here, on this side, by `events.js`, and pushed over finished.
23
+ * Nothing in the page is computed on a clock of its own. That is what makes minimising the
24
+ * window harmless: a browser slows a hidden page right down, and a page that is only drawing
25
+ * what it was handed loses nothing by being slow. It catches up the moment it is looked at.
26
+ *
27
+ * THREE. Nothing here ever waits on the window. `push` hands back nothing, so there is no
28
+ * promise to await by accident; every send has a hard timeout on it in `window.js`; and
29
+ * stopping the watcher never blocks on a window that has stopped answering.
30
+ *
31
+ * The division of labour across these four files: `events.js` decides WHAT to say, `panel.js`
32
+ * decides how it LOOKS, `window.js` owns the window it is said in, and this file is the seam
33
+ * between all of that and a check.
34
+ */
35
+
36
+ import { detail } from '../../core/log.js';
37
+ import { messageOf } from '../../core/errors.js';
38
+ import { openPanel } from './window.js';
39
+ import { attachPanel, panelPlan } from './events.js';
40
+
41
+ /** @typedef {import('../types.js').Journey} Journey */
42
+ /** @typedef {import('./events.js').PanelPlanShape} PanelPlan */
43
+ /** @typedef {import('./events.js').PanelReference} PanelReference */
44
+ /** @typedef {import('./window.js').Panel} Panel */
45
+ /** @typedef {import('./window.js').BesideThis} BesideThis */
46
+ /** @typedef {import('./window.js').PanelHealth} PanelHealth */
47
+
48
+ /** Panel width when nobody says otherwise. */
49
+ const DEFAULT_WIDTH = 480;
50
+
51
+ /** How long `snapTo` will wait for a window that is still opening before shrugging. */
52
+ const SNAP_WAIT_MS = 6000;
53
+
54
+ /**
55
+ * How long stopping will wait for a window that is STILL opening.
56
+ *
57
+ * Short, and it has to be. A check that finishes in two seconds on a machine where a browser
58
+ * takes twenty to start would otherwise sit there at the end, done, with nothing to say,
59
+ * waiting on a window nobody is going to read. So stopping calls the opening off and gives it
60
+ * a moment to tidy up; a window that arrives after that closes itself, because the opening
61
+ * sequence already knows it was stopped.
62
+ */
63
+ const STOP_WAIT_MS = 1500;
64
+
65
+ /**
66
+ * Anything with the two halves of an event stream on it.
67
+ *
68
+ * Written structurally rather than as the engine's own type so this can be handed a real
69
+ * check's stream, a stub in a test, or whatever the stream grows into next, without any of
70
+ * them having to import each other.
71
+ *
72
+ * @typedef {object} Watchable
73
+ * @property {(listener: (event: any) => void) => () => void} on
74
+ */
75
+
76
+ /**
77
+ * What the panel is told to do.
78
+ *
79
+ * `snap` lives here rather than in the shared options so that whether the two windows are
80
+ * pushed together stays a decision of the watch code.
81
+ *
82
+ * @typedef {import('../../types.js').WatchOptions & {snap?: boolean}} PanelOptions
83
+ */
84
+
85
+ /**
86
+ * @typedef {object} AttachOptions
87
+ * @property {string} [product] One repository can build five. This names the one being checked.
88
+ * @property {string} [project] The folder it is being run in.
89
+ * @property {Journey[]} [journeys] Every journey about to be walked, in order.
90
+ * @property {PanelReference} [reference] What it is being compared against, already in words.
91
+ * @property {PanelPlan} [plan] The finished plan, when a caller has built one itself.
92
+ * @property {PanelOptions} [watch]
93
+ * @property {string} [dir] Where to remember the window position. The project's own folder.
94
+ * @property {{width: number, height: number}} [appViewport]
95
+ */
96
+
97
+ /**
98
+ * A check's handle on its window.
99
+ *
100
+ * `snapTo` is how the check introduces the thing being observed once it is up. It is safe to
101
+ * call when there is no panel, when the panel cannot move windows, when the panel is still
102
+ * opening, and when the person asked for no snapping: in every one of those cases it does
103
+ * nothing and costs nothing.
104
+ *
105
+ * `health` is there so the claim this whole file makes — that the window never held the check
106
+ * up — has a number behind it rather than being taken on trust.
107
+ *
108
+ * @typedef {object} Watcher
109
+ * @property {() => Promise<void>} stop
110
+ * @property {(beside: BesideThis) => Promise<void>} snapTo
111
+ * @property {() => PanelHealth|null} health
112
+ * @property {() => boolean} open Is there a window right now.
113
+ */
114
+
115
+ /**
116
+ * Wait for something, but not for long, and never mind if it does not arrive.
117
+ *
118
+ * The whole of "stopping never blocks on a window", in five lines. The promise itself carries
119
+ * on — nothing here can cancel it, and it does not need to, because whatever it eventually
120
+ * produces knows it was stopped and puts itself away.
121
+ *
122
+ * @template T
123
+ * @param {Promise<T>} work
124
+ * @param {number} ms
125
+ * @returns {Promise<T|null>}
126
+ */
127
+ function soon(work, ms) {
128
+ /** @type {Promise<null>} */
129
+ const giveUp = new Promise((resolve) => {
130
+ const timer = setTimeout(() => resolve(null), ms);
131
+ // Never hold the program open waiting for a window.
132
+ if (typeof timer.unref === 'function') timer.unref();
133
+ });
134
+ return Promise.race([work.catch(() => null), giveUp]);
135
+ }
136
+
137
+ /**
138
+ * A watcher that is not watching anything.
139
+ *
140
+ * Handed back whenever there is no window — switched off, no browser, a page that would not
141
+ * build — so every caller has the same shape to stop, whatever happened.
142
+ *
143
+ * @returns {Watcher}
144
+ */
145
+ function noWatcher() {
146
+ return { stop: async () => {}, snapTo: async () => {}, health: () => null, open: () => false };
147
+ }
148
+
149
+ /**
150
+ * The opening plan: what the window draws before anything has happened.
151
+ *
152
+ * A window that opens empty and fills up looks broken for the first few seconds, so it is
153
+ * given the product, the surfaces in play and every journey it is about to walk, in order,
154
+ * from the moment it appears.
155
+ *
156
+ * @param {AttachOptions} opts
157
+ * @returns {PanelPlan}
158
+ */
159
+ function planFor(opts) {
160
+ if (opts.plan) return opts.plan;
161
+ return panelPlan({
162
+ ...(opts.product ? { product: opts.product } : {}),
163
+ ...(opts.project ? { project: opts.project } : {}),
164
+ ...(opts.journeys ? { journeys: opts.journeys } : {}),
165
+ ...(opts.reference ? { reference: opts.reference } : {}),
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Open a window and point it at a check.
171
+ *
172
+ * Never throws, and never waits for the window. Every way this can go wrong ends the same way:
173
+ * a line of detail, a watcher that does nothing, and a check that carries on at full speed.
174
+ *
175
+ * @param {Watchable} events
176
+ * @param {AttachOptions} [opts]
177
+ * @returns {Promise<Watcher>}
178
+ */
179
+ export async function attachWatcher(events, opts = {}) {
180
+ const watch = opts.watch ?? {};
181
+ if (watch.enabled === false) return noWatcher();
182
+ if (!events || typeof events.on !== 'function') return noWatcher();
183
+
184
+ const plan = { ...planFor(opts), theme: watch.theme ?? 'dark' };
185
+
186
+ /** @type {Panel|null} */
187
+ let panel = null;
188
+ /** @type {(() => void)|null} */
189
+ let unsubscribe = null;
190
+ let stopped = false;
191
+ // How stopping reaches a window that has not finished opening. Every wait inside `openPanel`
192
+ // watches this, so calling it off ends them all at once instead of one timeout at a time.
193
+ const givingUp = new AbortController();
194
+
195
+ /**
196
+ * Start the window and, when it is up, start feeding it.
197
+ *
198
+ * Nothing awaits this. Everything that can go wrong inside it ends with no panel and a check
199
+ * that never knew there was going to be one.
200
+ *
201
+ * @type {Promise<Panel|null>}
202
+ */
203
+ const opening = openPanel({
204
+ plan,
205
+ watch,
206
+ signal: givingUp.signal,
207
+ ...(opts.dir ? { dir: opts.dir } : {}),
208
+ ...(opts.appViewport ? { appViewport: opts.appViewport } : {}),
209
+ })
210
+ .then((open) => {
211
+ if (!open) return null;
212
+ if (stopped) {
213
+ // Stopped while it was still opening. Close it rather than leave a window nobody asked
214
+ // for standing on somebody's screen.
215
+ void open.close().catch(() => {});
216
+ return null;
217
+ }
218
+ panel = open;
219
+ // Everything that already happened arrives here first, in order, before the first live
220
+ // event does — which is the whole reason opening the window in the background is safe.
221
+ //
222
+ // `push` hands back nothing and swallows everything, so there is nothing in this
223
+ // callback that could hold a check up even by accident.
224
+ unsubscribe = attachPanel(events, (drawn) => open.push(drawn), {
225
+ plan,
226
+ onProblem: (problem) => {
227
+ detail(`The watch window refused an update. The check is unaffected. ${messageOf(problem)}`);
228
+ },
229
+ });
230
+ return open;
231
+ })
232
+ .catch((e) => {
233
+ // openPanel reports its own trouble and hands back null; this is only here for whatever
234
+ // it could not have seen coming.
235
+ detail(`The watch window could not open, so this check has no live view. ${messageOf(e)}`);
236
+ return null;
237
+ });
238
+
239
+ /**
240
+ * Wait a little for a window that is still opening, and give up cheerfully.
241
+ *
242
+ * Used only by `snapTo`, which happens once, early, at the one moment the window and the
243
+ * thing being checked are both about to exist. Everything else in here refuses to wait at all.
244
+ *
245
+ * @returns {Promise<Panel|null>}
246
+ */
247
+ async function panelSoon() {
248
+ if (panel) return panel;
249
+ return await soon(opening, SNAP_WAIT_MS);
250
+ }
251
+
252
+ const maySnap = watch.snap !== false;
253
+
254
+ /** @type {Promise<void>|null} */
255
+ let stopping = null;
256
+
257
+ return {
258
+ stop: () => {
259
+ stopping ??= (async () => {
260
+ stopped = true;
261
+ // Stop listening first, so nothing new is queued while we are putting it away.
262
+ try {
263
+ if (unsubscribe) unsubscribe();
264
+ } catch {
265
+ // Already gone. Nothing left to stop listening to.
266
+ }
267
+ // Call off a window that is still opening, and do not wait it out. A window that
268
+ // arrives late finds `stopped` already true and closes itself.
269
+ givingUp.abort();
270
+ const open = panel ?? (await soon(opening, STOP_WAIT_MS));
271
+ if (open) await open.close().catch(() => {});
272
+ })();
273
+ return stopping;
274
+ },
275
+
276
+ snapTo: async (beside) => {
277
+ if (!maySnap) return;
278
+ try {
279
+ const open = await panelSoon();
280
+ if (open) await open.snapTo(beside);
281
+ } catch (e) {
282
+ // Where two windows sit is a nicety. Losing it must never cost a check, and it is not
283
+ // worth a warning in the middle of a clean one.
284
+ detail(`The panel could not put itself beside what is being checked. ${messageOf(e)}`);
285
+ }
286
+ },
287
+
288
+ health: () => (panel ? panel.health() : null),
289
+ open: () => panel !== null,
290
+ };
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Settings
295
+ // ---------------------------------------------------------------------------
296
+
297
+ /**
298
+ * What the command line hands over. Anything the person did not type is left out, so the
299
+ * settings file still gets a say.
300
+ *
301
+ * @typedef {object} WatchFlags
302
+ * @property {boolean} [enabled]
303
+ * @property {'left'|'right'} [side]
304
+ * @property {number} [width]
305
+ * @property {number} [height]
306
+ * @property {boolean} [keepOpen]
307
+ * @property {boolean} [foreground]
308
+ * @property {boolean} [snap]
309
+ * @property {'dark'|'light'|'system'} [theme]
310
+ */
311
+
312
+ /**
313
+ * Settle what the panel should do: the settings file first, then anything typed on the command
314
+ * line, then the defaults.
315
+ *
316
+ * `--watch` can only ever turn the panel on. A person who did not type it has not said no to
317
+ * it — they have said nothing — so it never switches off a panel the settings file asked for.
318
+ *
319
+ * @param {{watch?: import('../../types.js').WatchOptions|boolean}|null} [config]
320
+ * @param {WatchFlags|null} [cli]
321
+ * @returns {PanelOptions}
322
+ */
323
+ export function watchOptionsFrom(config, cli) {
324
+ const raw = config?.watch;
325
+ // Read as PanelOptions on the way in: a settings file is free to carry a `snap` that the
326
+ // shared shape does not yet describe.
327
+ const settings = /** @type {PanelOptions} */ (
328
+ raw === true ? { enabled: true } : raw && typeof raw === 'object' ? raw : {}
329
+ );
330
+ const flags = cli ?? {};
331
+
332
+ const width = firstNumber(flags.width, settings.width) ?? DEFAULT_WIDTH;
333
+ const height = firstNumber(flags.height, settings.height);
334
+ const side = flags.side ?? settings.side ?? 'right';
335
+ const theme = flags.theme ?? settings.theme;
336
+
337
+ /** @type {PanelOptions} */
338
+ const merged = {
339
+ enabled: flags.enabled === true || settings.enabled === true,
340
+ width,
341
+ side: side === 'left' ? 'left' : 'right',
342
+ keepOpen: firstBoolean(flags.keepOpen, settings.keepOpen) ?? true,
343
+ // Never, unless somebody explicitly asks. This is the whole complaint: it must not keep
344
+ // bringing itself to the front.
345
+ foreground: firstBoolean(flags.foreground, settings.foreground) ?? false,
346
+ // On by default: the point of the panel is that it reads as a side panel of the thing it
347
+ // is checking, and it cannot do that sitting somewhere else on the screen.
348
+ snap: firstBoolean(flags.snap, settings.snap) ?? true,
349
+ // Dark unless somebody asks otherwise. The panel opens on a brand new browser profile, and
350
+ // a fresh profile insists the computer is in light mode however it is really set, so this
351
+ // is stated rather than detected.
352
+ theme: theme === 'light' || theme === 'system' ? theme : 'dark',
353
+ };
354
+ // Left out rather than guessed: with no height the panel is as tall as whatever it is
355
+ // standing next to.
356
+ if (height !== undefined) merged.height = height;
357
+ return merged;
358
+ }
359
+
360
+ /**
361
+ * @param {...unknown} values
362
+ * @returns {number|undefined}
363
+ */
364
+ function firstNumber(...values) {
365
+ for (const value of values) {
366
+ if (value === undefined || value === null || value === '') continue;
367
+ const n = Number(value);
368
+ if (Number.isFinite(n) && n > 0) return Math.round(n);
369
+ }
370
+ return undefined;
371
+ }
372
+
373
+ /**
374
+ * @param {...unknown} values
375
+ * @returns {boolean|undefined}
376
+ */
377
+ function firstBoolean(...values) {
378
+ for (const value of values) {
379
+ if (typeof value === 'boolean') return value;
380
+ }
381
+ return undefined;
382
+ }