@velumo/cli 0.1.0-beta.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.
Files changed (38) hide show
  1. package/dist/browser-overview.d.ts +72 -0
  2. package/dist/browser-overview.d.ts.map +1 -0
  3. package/dist/browser-overview.js +431 -0
  4. package/dist/browser-overview.js.map +1 -0
  5. package/dist/browser-presenter.d.ts +63 -0
  6. package/dist/browser-presenter.d.ts.map +1 -0
  7. package/dist/browser-presenter.js +304 -0
  8. package/dist/browser-presenter.js.map +1 -0
  9. package/dist/browser-runtime.d.ts +43 -0
  10. package/dist/browser-runtime.d.ts.map +1 -0
  11. package/dist/browser-runtime.js +293 -0
  12. package/dist/browser-runtime.js.map +1 -0
  13. package/dist/browser-spatial.d.ts +42 -0
  14. package/dist/browser-spatial.d.ts.map +1 -0
  15. package/dist/browser-spatial.js +207 -0
  16. package/dist/browser-spatial.js.map +1 -0
  17. package/dist/browser-sync.d.ts +37 -0
  18. package/dist/browser-sync.d.ts.map +1 -0
  19. package/dist/browser-sync.js +77 -0
  20. package/dist/browser-sync.js.map +1 -0
  21. package/dist/browser-timer.d.ts +9 -0
  22. package/dist/browser-timer.d.ts.map +1 -0
  23. package/dist/browser-timer.js +13 -0
  24. package/dist/browser-timer.js.map +1 -0
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +902 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/tsconfig.tsbuildinfo +1 -0
  30. package/package.json +42 -0
  31. package/src/browser-overview.ts +641 -0
  32. package/src/browser-presenter.ts +602 -0
  33. package/src/browser-runtime.ts +493 -0
  34. package/src/browser-spatial.ts +292 -0
  35. package/src/browser-sync.ts +151 -0
  36. package/src/browser-timer.ts +24 -0
  37. package/src/index.ts +1273 -0
  38. package/tsconfig.json +16 -0
@@ -0,0 +1,641 @@
1
+ import type { Manifest } from "@velumo/core";
2
+ import type { RuntimeController } from "@velumo/runtime";
3
+
4
+ const OVERVIEW_ATTRIBUTE = "data-velumo-overview";
5
+ const OVERVIEW_OVERLAY_ATTRIBUTE = "data-velumo-overview-overlay";
6
+ const OVERVIEW_GRID_ATTRIBUTE = "data-velumo-overview-grid";
7
+ const OVERVIEW_BUTTON_ATTRIBUTE = "data-velumo-overview-button";
8
+ const OVERVIEW_SLIDE_ID_ATTRIBUTE = "data-velumo-overview-slide-id";
9
+ const OVERVIEW_STYLE_ATTRIBUTE = "data-velumo-overview-style";
10
+ const OVERVIEW_ARIA_LABEL = "Slide overview";
11
+
12
+ const OVERVIEW_CSS = `
13
+ [${OVERVIEW_ATTRIBUTE}="open"] [${OVERVIEW_OVERLAY_ATTRIBUTE}] {
14
+ position: fixed;
15
+ inset: 0;
16
+ z-index: 9999;
17
+ display: grid;
18
+ grid-template-rows: auto 1fr;
19
+ gap: 1rem;
20
+ padding: 1.5rem;
21
+ box-sizing: border-box;
22
+ background: var(--velumo-color-background, #0b1220);
23
+ color: var(--velumo-color-foreground, #f8fafc);
24
+ font-family: var(--velumo-typography-body-font-family, system-ui, sans-serif);
25
+ overflow: auto;
26
+ }
27
+
28
+ [${OVERVIEW_ATTRIBUTE}="closed"] [${OVERVIEW_OVERLAY_ATTRIBUTE}] {
29
+ display: none;
30
+ }
31
+
32
+ [${OVERVIEW_OVERLAY_ATTRIBUTE}] h2 {
33
+ margin: 0;
34
+ font-family: var(--velumo-typography-body-font-family, inherit);
35
+ color: var(--velumo-color-foreground, currentColor);
36
+ }
37
+
38
+ [${OVERVIEW_GRID_ATTRIBUTE}] {
39
+ display: grid;
40
+ grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
41
+ gap: 0.75rem;
42
+ align-content: start;
43
+ }
44
+
45
+ [${OVERVIEW_BUTTON_ATTRIBUTE}] {
46
+ background: var(--velumo-color-background, transparent);
47
+ color: var(--velumo-color-foreground, inherit);
48
+ border: 1px solid var(--velumo-color-foreground, currentColor);
49
+ border-radius: 0.5rem;
50
+ padding: 0.75rem 0.85rem;
51
+ text-align: left;
52
+ font: inherit;
53
+ font-family: var(--velumo-typography-body-font-family, inherit);
54
+ cursor: pointer;
55
+ display: grid;
56
+ gap: 0.2rem;
57
+ align-self: start;
58
+ min-height: 5rem;
59
+ }
60
+
61
+ [${OVERVIEW_BUTTON_ATTRIBUTE}] > span:nth-child(1) {
62
+ font-size: 0.7rem;
63
+ opacity: 0.55;
64
+ letter-spacing: 0.04em;
65
+ }
66
+
67
+ [${OVERVIEW_BUTTON_ATTRIBUTE}] > span:nth-child(2) {
68
+ font-size: 0.95rem;
69
+ font-weight: 600;
70
+ word-break: break-word;
71
+ }
72
+
73
+ [${OVERVIEW_BUTTON_ATTRIBUTE}] > span:nth-child(3) {
74
+ font-size: 0.65rem;
75
+ opacity: 0.5;
76
+ text-transform: uppercase;
77
+ letter-spacing: 0.08em;
78
+ }
79
+
80
+ [${OVERVIEW_BUTTON_ATTRIBUTE}] > span:nth-child(4) {
81
+ font-size: 0.85rem;
82
+ opacity: 0.75;
83
+ margin-top: 0.2rem;
84
+ word-break: break-word;
85
+ }
86
+
87
+ [${OVERVIEW_BUTTON_ATTRIBUTE}][aria-current="true"] {
88
+ border-color: var(--velumo-color-accent, currentColor);
89
+ box-shadow: 0 0 0 2px var(--velumo-color-accent, currentColor) inset;
90
+ }
91
+
92
+ [data-velumo-thumbnail-host] {
93
+ width: 100%;
94
+ aspect-ratio: 16 / 9;
95
+ overflow: hidden;
96
+ border-radius: 0.25rem;
97
+ background: var(--velumo-color-background, #0b1220);
98
+ margin-bottom: 0.25rem;
99
+ flex-shrink: 0;
100
+ }
101
+
102
+ /* Thumbnail mode: the button's children are [thumbnail-host, index, slide-id],
103
+ so the positional span:nth-child rules above would style the caption by
104
+ accident. Style the caption intentionally here — a small index prefix over a
105
+ readable slide-id label (the useful identifier on a contact sheet). */
106
+ [${OVERVIEW_BUTTON_ATTRIBUTE}][data-velumo-overview-thumb] > span:nth-child(2) {
107
+ font-size: 0.7rem;
108
+ font-weight: 400;
109
+ opacity: 0.55;
110
+ text-transform: none;
111
+ letter-spacing: 0.04em;
112
+ }
113
+
114
+ [${OVERVIEW_BUTTON_ATTRIBUTE}][data-velumo-overview-thumb] > span:nth-child(3) {
115
+ font-size: 0.85rem;
116
+ font-weight: 600;
117
+ opacity: 0.9;
118
+ text-transform: none;
119
+ letter-spacing: normal;
120
+ word-break: break-word;
121
+ }
122
+
123
+ [data-velumo-overview-sr] {
124
+ position: absolute;
125
+ width: 1px;
126
+ height: 1px;
127
+ padding: 0;
128
+ margin: -1px;
129
+ overflow: hidden;
130
+ clip: rect(0 0 0 0);
131
+ clip-path: inset(50%);
132
+ white-space: nowrap;
133
+ border: 0;
134
+ }
135
+ `;
136
+
137
+ interface OverviewKeyboardEvent {
138
+ readonly key: string;
139
+ readonly target: unknown;
140
+ preventDefault(): void;
141
+ }
142
+
143
+ interface OverviewClickEvent {
144
+ readonly type: string;
145
+ }
146
+
147
+ type OverviewChild = OverviewElementLike | OverviewTextNodeLike | string;
148
+
149
+ interface OverviewTextNodeLike {
150
+ readonly textContent: string;
151
+ }
152
+
153
+ export interface OverviewElementLike {
154
+ readonly ownerDocument: OverviewDocumentLike;
155
+ readonly tagName?: string;
156
+ readonly isContentEditable?: boolean;
157
+ addEventListener(
158
+ type: "click",
159
+ listener: (event: OverviewClickEvent) => void,
160
+ ): void;
161
+ append(...children: OverviewChild[]): void;
162
+ appendChild(
163
+ child: OverviewElementLike | OverviewTextNodeLike,
164
+ ): OverviewElementLike | OverviewTextNodeLike;
165
+ getAttribute(name: string): string | null;
166
+ remove?(): void;
167
+ removeAttribute(name: string): void;
168
+ replaceChildren(...children: OverviewChild[]): void;
169
+ setAttribute(name: string, value: string): void;
170
+ focus?(): void;
171
+ }
172
+
173
+ export interface OverviewRootLike {
174
+ readonly ownerDocument: OverviewDocumentLike;
175
+ focus?(): void;
176
+ }
177
+
178
+ export interface OverviewDocumentLike {
179
+ readonly head?: OverviewElementLike;
180
+ readonly body?: OverviewElementLike;
181
+ createElement(tagName: string): OverviewElementLike;
182
+ createTextNode(text: string): OverviewTextNodeLike;
183
+ }
184
+
185
+ interface OverviewBroadcastChannelConstructor {
186
+ new (name: string): unknown;
187
+ }
188
+
189
+ export interface OverviewWindowLike {
190
+ readonly BroadcastChannel?: OverviewBroadcastChannelConstructor;
191
+ readonly crypto?: { randomUUID?(): string };
192
+ addEventListener(
193
+ type: "keydown",
194
+ listener: (event: OverviewKeyboardEvent) => void,
195
+ ): void;
196
+ removeEventListener(
197
+ type: "keydown",
198
+ listener: (event: OverviewKeyboardEvent) => void,
199
+ ): void;
200
+ }
201
+
202
+ export type OverviewRoot = OverviewRootLike;
203
+
204
+ export interface OverviewMountOptions {
205
+ readonly root: OverviewRoot | null | undefined;
206
+ readonly runtime: RuntimeController;
207
+ readonly manifest: Manifest;
208
+ readonly window?: OverviewWindowLike;
209
+ readonly document?: OverviewDocumentLike;
210
+ readonly host?: OverviewElementLike;
211
+ readonly onAfterJump?: () => void;
212
+ readonly mountThumbnail?: (opts: {
213
+ root: OverviewElementLike;
214
+ manifest: Manifest;
215
+ slideId: string;
216
+ }) => { dispose(): void };
217
+ }
218
+
219
+ export interface OverviewMountResult {
220
+ dispose(): void;
221
+ }
222
+
223
+ interface ActiveMountRecord {
224
+ active: boolean;
225
+ teardown(): void;
226
+ }
227
+
228
+ const overviewRegistry = new WeakMap<OverviewWindowLike, ActiveMountRecord>();
229
+
230
+ export function setupOverviewMode(
231
+ options: OverviewMountOptions,
232
+ ): OverviewMountResult {
233
+ if (options.root === null || options.root === undefined) {
234
+ throw new Error("Velumo overview root element not found");
235
+ }
236
+
237
+ const root = options.root;
238
+ const browserWindow = options.window ?? getGlobalWindow();
239
+
240
+ if (browserWindow === undefined) {
241
+ throw new Error("Velumo overview window is not available");
242
+ }
243
+
244
+ const browserDocument =
245
+ options.document ?? getDocumentFromRoot(root) ?? getGlobalDocument();
246
+
247
+ if (browserDocument === undefined) {
248
+ throw new Error("Velumo overview document is not available");
249
+ }
250
+
251
+ const documentForOverview: OverviewDocumentLike = browserDocument;
252
+ const fallbackHost = documentForOverview.body ?? fallbackHostFromRoot(root);
253
+
254
+ if (options.host === undefined && fallbackHost === undefined) {
255
+ throw new Error("Velumo overview host element is not available");
256
+ }
257
+
258
+ const host: OverviewElementLike = options.host ?? fallbackHost!;
259
+
260
+ const previous = overviewRegistry.get(browserWindow);
261
+ if (previous !== undefined) {
262
+ previous.active = false;
263
+ overviewRegistry.delete(browserWindow);
264
+ previous.teardown();
265
+ }
266
+
267
+ ensureStyleInjected(documentForOverview);
268
+
269
+ let overlay: OverviewElementLike | undefined;
270
+ let stateChangeUnsubscribe: (() => void) | undefined;
271
+ let thumbnailHandles: Array<{ dispose(): void }> = [];
272
+ host.setAttribute(OVERVIEW_ATTRIBUTE, "closed");
273
+
274
+ function disposeThumbnails(): void {
275
+ for (const handle of thumbnailHandles) {
276
+ handle.dispose();
277
+ }
278
+ thumbnailHandles = [];
279
+ }
280
+
281
+ function isOpen(): boolean {
282
+ return overlay !== undefined;
283
+ }
284
+
285
+ function openOverview(): void {
286
+ if (isOpen()) {
287
+ return;
288
+ }
289
+
290
+ const mountQueue: Array<{ host: OverviewElementLike; slideId: string }> =
291
+ [];
292
+ overlay = renderOverlay(
293
+ documentForOverview,
294
+ options.runtime,
295
+ options.manifest,
296
+ options.mountThumbnail,
297
+ mountQueue,
298
+ );
299
+ host.append(overlay);
300
+ host.setAttribute(OVERVIEW_ATTRIBUTE, "open");
301
+
302
+ // Second pass: mount thumbnails after overlay is in the DOM so each host
303
+ // has a definite layout width for scale calculations.
304
+ if (options.mountThumbnail !== undefined) {
305
+ for (const { host: thumbHost, slideId } of mountQueue) {
306
+ const handle = options.mountThumbnail({
307
+ root: thumbHost,
308
+ manifest: options.manifest,
309
+ slideId,
310
+ });
311
+ thumbnailHandles.push(handle);
312
+ }
313
+ }
314
+
315
+ moveFocusToCurrentButton(overlay, options.runtime);
316
+ stateChangeUnsubscribe = options.runtime.on("state:change", () => {
317
+ refreshCurrentHighlight(overlay, options.runtime);
318
+ });
319
+ }
320
+
321
+ function closeOverview(): void {
322
+ if (!isOpen()) {
323
+ return;
324
+ }
325
+
326
+ stateChangeUnsubscribe?.();
327
+ stateChangeUnsubscribe = undefined;
328
+
329
+ disposeThumbnails();
330
+
331
+ if (overlay !== undefined) {
332
+ removeFromHost(host, overlay);
333
+ overlay = undefined;
334
+ }
335
+
336
+ host.setAttribute(OVERVIEW_ATTRIBUTE, "closed");
337
+ root.focus?.();
338
+ }
339
+
340
+ function handleKeydown(event: OverviewKeyboardEvent): void {
341
+ if (event.key === "Escape") {
342
+ if (isOpen()) {
343
+ closeOverview();
344
+ event.preventDefault();
345
+ }
346
+ return;
347
+ }
348
+
349
+ if (isEditableTarget(event.target)) {
350
+ return;
351
+ }
352
+
353
+ if (event.key === "o" && !isOpen()) {
354
+ openOverview();
355
+ event.preventDefault();
356
+ }
357
+ }
358
+
359
+ function jumpAndClose(slideId: string): void {
360
+ const changed = options.runtime.jumpToSlide(slideId);
361
+ if (changed) {
362
+ options.onAfterJump?.();
363
+ }
364
+ closeOverview();
365
+ }
366
+
367
+ function renderOverlay(
368
+ document: OverviewDocumentLike,
369
+ runtime: RuntimeController,
370
+ manifest: Manifest,
371
+ mountThumbnailOption: OverviewMountOptions["mountThumbnail"] | undefined,
372
+ mountQueue: Array<{ host: OverviewElementLike; slideId: string }>,
373
+ ): OverviewElementLike {
374
+ const overlayElement = document.createElement("div");
375
+ overlayElement.setAttribute("role", "dialog");
376
+ overlayElement.setAttribute("aria-label", OVERVIEW_ARIA_LABEL);
377
+ overlayElement.setAttribute(OVERVIEW_OVERLAY_ATTRIBUTE, "");
378
+
379
+ const heading = document.createElement("h2");
380
+ heading.append(OVERVIEW_ARIA_LABEL);
381
+ overlayElement.append(heading);
382
+
383
+ const grid = document.createElement("div");
384
+ grid.setAttribute(OVERVIEW_GRID_ATTRIBUTE, "");
385
+
386
+ const currentSlideId = runtime.getState().slideId;
387
+
388
+ manifest.slides.forEach((slide, index) => {
389
+ const button = document.createElement("button");
390
+ button.setAttribute("type", "button");
391
+ button.setAttribute(OVERVIEW_BUTTON_ATTRIBUTE, "");
392
+ button.setAttribute(OVERVIEW_SLIDE_ID_ATTRIBUTE, slide.id);
393
+ if (slide.id === currentSlideId) {
394
+ button.setAttribute("aria-current", "true");
395
+ }
396
+
397
+ const indexLabel = document.createElement("span");
398
+ indexLabel.append(`${index + 1}`);
399
+ const idLabel = document.createElement("span");
400
+ idLabel.append(slide.id);
401
+
402
+ if (mountThumbnailOption !== undefined) {
403
+ // Thumbnail mode: aria-hidden host + index badge + slide-id caption.
404
+ // The rendered thumbnail (aria-hidden) replaces the kind/heading VISUALLY,
405
+ // but the heading/kind is kept as visually-hidden text so screen-reader
406
+ // users can still identify slides with non-descriptive ids.
407
+ button.setAttribute("data-velumo-overview-thumb", "");
408
+ const thumbnailHost = document.createElement("div");
409
+ thumbnailHost.setAttribute("aria-hidden", "true");
410
+ thumbnailHost.setAttribute("data-velumo-thumbnail-host", "");
411
+ button.append(thumbnailHost, indexLabel, idLabel);
412
+ const headingText = findFirstHeadingText(
413
+ (slide as { layout?: unknown }).layout,
414
+ );
415
+ const srText =
416
+ headingText !== undefined && headingText !== ""
417
+ ? `${slide.kind}, ${headingText}`
418
+ : slide.kind;
419
+ const srLabel = document.createElement("span");
420
+ srLabel.setAttribute("data-velumo-overview-sr", "");
421
+ srLabel.append(srText);
422
+ button.append(srLabel);
423
+ // Record for the second-pass mount (after overlay is in the DOM).
424
+ mountQueue.push({ host: thumbnailHost, slideId: slide.id });
425
+ } else {
426
+ // Text-card mode (no mountThumbnail): keep existing behavior.
427
+ const kindLabel = document.createElement("span");
428
+ kindLabel.append(slide.kind);
429
+ button.append(indexLabel, idLabel, kindLabel);
430
+
431
+ const headingText = findFirstHeadingText(
432
+ (slide as { layout?: unknown }).layout,
433
+ );
434
+ if (headingText !== undefined && headingText !== "") {
435
+ const previewLabel = document.createElement("span");
436
+ previewLabel.append(headingText);
437
+ button.append(previewLabel);
438
+ }
439
+ }
440
+
441
+ button.addEventListener("click", () => jumpAndClose(slide.id));
442
+ grid.append(button);
443
+ });
444
+
445
+ overlayElement.append(grid);
446
+ return overlayElement;
447
+ }
448
+
449
+ browserWindow.addEventListener("keydown", handleKeydown);
450
+
451
+ const record: ActiveMountRecord = {
452
+ active: true,
453
+ teardown() {
454
+ browserWindow.removeEventListener("keydown", handleKeydown);
455
+ stateChangeUnsubscribe?.();
456
+ stateChangeUnsubscribe = undefined;
457
+ disposeThumbnails();
458
+ if (overlay !== undefined) {
459
+ removeFromHost(host, overlay);
460
+ overlay = undefined;
461
+ }
462
+ host.removeAttribute(OVERVIEW_ATTRIBUTE);
463
+ },
464
+ };
465
+ overviewRegistry.set(browserWindow, record);
466
+
467
+ return {
468
+ dispose() {
469
+ if (!record.active) {
470
+ return;
471
+ }
472
+ record.active = false;
473
+ record.teardown();
474
+ const current = overviewRegistry.get(browserWindow);
475
+ if (current === record) {
476
+ overviewRegistry.delete(browserWindow);
477
+ }
478
+ },
479
+ };
480
+ }
481
+
482
+ function ensureStyleInjected(document: OverviewDocumentLike): void {
483
+ const head = document.head;
484
+ if (head === undefined) {
485
+ return;
486
+ }
487
+
488
+ const existing = headHasStyleElement(head);
489
+ if (existing) {
490
+ return;
491
+ }
492
+
493
+ const style = document.createElement("style");
494
+ style.setAttribute(OVERVIEW_STYLE_ATTRIBUTE, "");
495
+ style.append(OVERVIEW_CSS);
496
+ head.append(style);
497
+ }
498
+
499
+ function headHasStyleElement(head: OverviewElementLike): boolean {
500
+ const queryable = head as {
501
+ querySelector?(selector: string): OverviewElementLike | null;
502
+ };
503
+ if (typeof queryable.querySelector === "function") {
504
+ return queryable.querySelector(`[${OVERVIEW_STYLE_ATTRIBUTE}]`) !== null;
505
+ }
506
+ return false;
507
+ }
508
+
509
+ function moveFocusToCurrentButton(
510
+ overlay: OverviewElementLike,
511
+ runtime: RuntimeController,
512
+ ): void {
513
+ const buttons = listButtons(overlay);
514
+ if (buttons.length === 0) {
515
+ return;
516
+ }
517
+
518
+ const currentSlideId = runtime.getState().slideId;
519
+ const currentButton =
520
+ buttons.find(
521
+ (button) =>
522
+ button.getAttribute(OVERVIEW_SLIDE_ID_ATTRIBUTE) === currentSlideId,
523
+ ) ?? buttons[0];
524
+
525
+ currentButton.focus?.();
526
+ }
527
+
528
+ function refreshCurrentHighlight(
529
+ overlay: OverviewElementLike | undefined,
530
+ runtime: RuntimeController,
531
+ ): void {
532
+ if (overlay === undefined) {
533
+ return;
534
+ }
535
+
536
+ const currentSlideId = runtime.getState().slideId;
537
+ for (const button of listButtons(overlay)) {
538
+ if (button.getAttribute(OVERVIEW_SLIDE_ID_ATTRIBUTE) === currentSlideId) {
539
+ button.setAttribute("aria-current", "true");
540
+ } else {
541
+ button.removeAttribute("aria-current");
542
+ }
543
+ }
544
+ }
545
+
546
+ function listButtons(overlay: OverviewElementLike): OverviewElementLike[] {
547
+ const queryable = overlay as {
548
+ querySelectorAll?(selector: string): OverviewElementLike[];
549
+ };
550
+
551
+ if (typeof queryable.querySelectorAll === "function") {
552
+ const collected = queryable.querySelectorAll(
553
+ `[${OVERVIEW_BUTTON_ATTRIBUTE}]`,
554
+ );
555
+ return Array.from(collected as ArrayLike<OverviewElementLike>);
556
+ }
557
+
558
+ return [];
559
+ }
560
+
561
+ function removeFromHost(
562
+ host: OverviewElementLike,
563
+ child: OverviewElementLike,
564
+ ): void {
565
+ if (typeof child.remove === "function") {
566
+ child.remove();
567
+ return;
568
+ }
569
+
570
+ const removable = host as {
571
+ removeChild?(child: OverviewElementLike): OverviewElementLike;
572
+ };
573
+ removable.removeChild?.(child);
574
+ }
575
+
576
+ function isEditableTarget(target: unknown): boolean {
577
+ if (target === null || typeof target !== "object") {
578
+ return false;
579
+ }
580
+
581
+ const candidate = target as {
582
+ readonly isContentEditable?: boolean;
583
+ readonly tagName?: string;
584
+ };
585
+
586
+ if (candidate.isContentEditable === true) {
587
+ return true;
588
+ }
589
+
590
+ const tagName = candidate.tagName?.toLowerCase();
591
+ return (
592
+ tagName === "input" ||
593
+ tagName === "textarea" ||
594
+ tagName === "select" ||
595
+ tagName === "button"
596
+ );
597
+ }
598
+
599
+ function findFirstHeadingText(node: unknown): string | undefined {
600
+ if (typeof node !== "object" || node === null) {
601
+ return undefined;
602
+ }
603
+ const candidate = node as {
604
+ readonly kind?: string;
605
+ readonly text?: string;
606
+ readonly children?: readonly unknown[];
607
+ };
608
+ if (candidate.kind === "heading" && typeof candidate.text === "string") {
609
+ return candidate.text;
610
+ }
611
+ if (Array.isArray(candidate.children)) {
612
+ for (const child of candidate.children) {
613
+ const found = findFirstHeadingText(child);
614
+ if (found !== undefined) {
615
+ return found;
616
+ }
617
+ }
618
+ }
619
+ return undefined;
620
+ }
621
+
622
+ function getGlobalWindow(): OverviewWindowLike | undefined {
623
+ return (globalThis as { window?: OverviewWindowLike }).window;
624
+ }
625
+
626
+ function getGlobalDocument(): OverviewDocumentLike | undefined {
627
+ return (globalThis as { document?: OverviewDocumentLike }).document;
628
+ }
629
+
630
+ function getDocumentFromRoot(
631
+ root: OverviewRootLike,
632
+ ): OverviewDocumentLike | undefined {
633
+ return root.ownerDocument;
634
+ }
635
+
636
+ function fallbackHostFromRoot(
637
+ root: OverviewRootLike,
638
+ ): OverviewElementLike | undefined {
639
+ const document = getDocumentFromRoot(root);
640
+ return document?.body;
641
+ }