diff --git a/backlog/tasks/task-20.2 - Add-renderer-to-main-IPC-contract-for-measured-overlay-content-bounds.md b/backlog/tasks/task-20.2 - Add-renderer-to-main-IPC-contract-for-measured-overlay-content-bounds.md index d3ba2eb..bbc6fd2 100644 --- a/backlog/tasks/task-20.2 - Add-renderer-to-main-IPC-contract-for-measured-overlay-content-bounds.md +++ b/backlog/tasks/task-20.2 - Add-renderer-to-main-IPC-contract-for-measured-overlay-content-bounds.md @@ -1,10 +1,10 @@ --- id: TASK-20.2 title: Add renderer-to-main IPC contract for measured overlay content bounds -status: To Do +status: Done assignee: [] created_date: '2026-02-12 08:47' -updated_date: '2026-02-12 09:42' +updated_date: '2026-02-12 02:45' labels: [] dependencies: [] parent_task_id: TASK-20 @@ -19,8 +19,22 @@ Add renderer-to-main IPC for content measurement reporting, so main process can ## Acceptance Criteria -- [ ] #1 Preload exposes a typed API for reporting overlay content bounds with layer metadata. -- [ ] #2 Main-process IPC handler validates payload shape/range and stores latest measurement per layer. -- [ ] #3 Renderer emits measurement updates on subtitle, mode, style, and render-metric changes with throttling/debounce. -- [ ] #4 No crashes or unbounded logging when measurements are missing/empty/invalid; fallback behavior is explicit. +- [x] #1 Preload exposes a typed API for reporting overlay content bounds with layer metadata. +- [x] #2 Main-process IPC handler validates payload shape/range and stores latest measurement per layer. +- [x] #3 Renderer emits measurement updates on subtitle, mode, style, and render-metric changes with throttling/debounce. +- [x] #4 No crashes or unbounded logging when measurements are missing/empty/invalid; fallback behavior is explicit. + +## Implementation Notes + + +Added a typed `OverlayContentMeasurement` IPC contract exposed in preload and Electron API typings. Implemented a main-process measurement store with strict payload validation and rate-limited warning logs for invalid reports. Added renderer-side debounced measurement reporting that emits updates on subtitle content/mode/style/render-metric and resize changes, explicitly sending `contentRect: null` when no measured content exists to signal fallback behavior. + +Added unit coverage for measurement validation and store behavior. + + +## Final Summary + + +Implemented renderer-to-main measurement reporting for overlay content bounds with per-layer metadata. Main now validates and stores latest measurements per layer safely, renderer emits debounced updates on relevant state changes, and invalid/missing payload handling is explicit and non-spammy. + diff --git a/package.json b/package.json index a5b616b..d8238a7 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "test:config": "pnpm run build && node --test dist/config/config.test.js", - "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining-service.test.js dist/core/services/anki-jimaku-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js", + "test:core": "pnpm run build && node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/overlay-content-measurement-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining-service.test.js dist/core/services/anki-jimaku-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js", "test:subtitle": "pnpm run build && node --test dist/subtitle/stages.test.js dist/subtitle/pipeline.test.js", "generate:config-example": "pnpm run build && node dist/generate-config-example.js", "start": "pnpm run build && electron . --start", diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 396e9df..62946ef 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -69,6 +69,7 @@ export { DEFAULT_MPV_SUBTITLE_RENDER_METRICS, sanitizeMpvSubtitleRenderMetrics, } from "./mpv-render-metrics-service"; +export { createOverlayContentMeasurementStoreService } from "./overlay-content-measurement-service"; export { handleMpvCommandFromIpcService } from "./ipc-command-service"; export { createFieldGroupingOverlayRuntimeService } from "./field-grouping-overlay-service"; export { createNumericShortcutRuntimeService } from "./numeric-shortcut-service"; diff --git a/src/core/services/ipc-service.ts b/src/core/services/ipc-service.ts index 137afa4..d175efb 100644 --- a/src/core/services/ipc-service.ts +++ b/src/core/services/ipc-service.ts @@ -28,6 +28,7 @@ export interface IpcServiceDeps { getRuntimeOptions: () => unknown; setRuntimeOption: (id: string, value: unknown) => unknown; cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; + reportOverlayContentBounds: (payload: unknown) => void; } interface WindowLike { @@ -75,6 +76,7 @@ export interface IpcDepsRuntimeOptions { getRuntimeOptions: () => unknown; setRuntimeOption: (id: string, value: unknown) => unknown; cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; + reportOverlayContentBounds: (payload: unknown) => void; } export function createIpcDepsRuntimeService( @@ -126,6 +128,7 @@ export function createIpcDepsRuntimeService( getRuntimeOptions: options.getRuntimeOptions, setRuntimeOption: options.setRuntimeOption, cycleRuntimeOption: options.cycleRuntimeOption, + reportOverlayContentBounds: options.reportOverlayContentBounds, }; } @@ -253,4 +256,8 @@ export function registerIpcHandlersService(deps: IpcServiceDeps): void { ipcMain.handle("runtime-options:cycle", (_event, id: string, direction: 1 | -1) => { return deps.cycleRuntimeOption(id, direction); }); + + ipcMain.on("overlay-content-bounds:report", (_event: IpcMainEvent, payload: unknown) => { + deps.reportOverlayContentBounds(payload); + }); } diff --git a/src/core/services/overlay-content-measurement-service.test.ts b/src/core/services/overlay-content-measurement-service.test.ts new file mode 100644 index 0000000..47dbb99 --- /dev/null +++ b/src/core/services/overlay-content-measurement-service.test.ts @@ -0,0 +1,87 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + createOverlayContentMeasurementStoreService, + sanitizeOverlayContentMeasurement, +} from "./overlay-content-measurement-service"; + +test("sanitizeOverlayContentMeasurement accepts valid payload with null rect", () => { + const measurement = sanitizeOverlayContentMeasurement( + { + layer: "visible", + measuredAtMs: 100, + viewport: { width: 1920, height: 1080 }, + contentRect: null, + }, + 500, + ); + + assert.deepEqual(measurement, { + layer: "visible", + measuredAtMs: 100, + viewport: { width: 1920, height: 1080 }, + contentRect: null, + }); +}); + +test("sanitizeOverlayContentMeasurement rejects invalid ranges", () => { + const measurement = sanitizeOverlayContentMeasurement( + { + layer: "invisible", + measuredAtMs: 100, + viewport: { width: 0, height: 1080 }, + contentRect: { x: 0, y: 0, width: 100, height: 20 }, + }, + 500, + ); + + assert.equal(measurement, null); +}); + +test("overlay measurement store keeps latest payload per layer", () => { + const store = createOverlayContentMeasurementStoreService({ + now: () => 1000, + warn: () => { + // noop + }, + }); + + const visible = store.report({ + layer: "visible", + measuredAtMs: 900, + viewport: { width: 1280, height: 720 }, + contentRect: { x: 50, y: 60, width: 400, height: 80 }, + }); + const invisible = store.report({ + layer: "invisible", + measuredAtMs: 910, + viewport: { width: 1280, height: 720 }, + contentRect: { x: 20, y: 30, width: 300, height: 40 }, + }); + + assert.equal(visible?.layer, "visible"); + assert.equal(invisible?.layer, "invisible"); + assert.equal(store.getLatestByLayer("visible")?.contentRect?.width, 400); + assert.equal(store.getLatestByLayer("invisible")?.contentRect?.height, 40); +}); + +test("overlay measurement store rate-limits invalid payload warnings", () => { + let now = 1_000; + const warnings: string[] = []; + const store = createOverlayContentMeasurementStoreService({ + now: () => now, + warn: (message) => { + warnings.push(message); + }, + }); + + store.report({ layer: "visible" }); + store.report({ layer: "visible" }); + assert.equal(warnings.length, 0); + + now = 11_000; + store.report({ layer: "visible" }); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /Dropped 3 invalid measurement payload/); +}); diff --git a/src/core/services/overlay-content-measurement-service.ts b/src/core/services/overlay-content-measurement-service.ts new file mode 100644 index 0000000..8eadb25 --- /dev/null +++ b/src/core/services/overlay-content-measurement-service.ts @@ -0,0 +1,149 @@ +import { OverlayContentMeasurement, OverlayContentRect, OverlayLayer } from "../../types"; +const MAX_VIEWPORT = 10000; +const MAX_RECT_DIMENSION = 10000; +const MAX_RECT_OFFSET = 50000; +const MAX_FUTURE_TIMESTAMP_MS = 60_000; +const INVALID_LOG_THROTTLE_MS = 10_000; + +type OverlayMeasurementStore = Record; + +export function sanitizeOverlayContentMeasurement( + payload: unknown, + nowMs: number, +): OverlayContentMeasurement | null { + if (!payload || typeof payload !== "object") return null; + + const candidate = payload as { + layer?: unknown; + measuredAtMs?: unknown; + viewport?: { width?: unknown; height?: unknown }; + contentRect?: { x?: unknown; y?: unknown; width?: unknown; height?: unknown } | null; + }; + + if (candidate.layer !== "visible" && candidate.layer !== "invisible") { + return null; + } + + const viewportWidth = readFiniteInRange(candidate.viewport?.width, 1, MAX_VIEWPORT); + const viewportHeight = readFiniteInRange(candidate.viewport?.height, 1, MAX_VIEWPORT); + + if (!Number.isFinite(viewportWidth) || !Number.isFinite(viewportHeight)) { + return null; + } + + const measuredAtMs = readFiniteInRange( + candidate.measuredAtMs, + 1, + nowMs + MAX_FUTURE_TIMESTAMP_MS, + ); + if (!Number.isFinite(measuredAtMs)) { + return null; + } + + const contentRect = sanitizeOverlayContentRect(candidate.contentRect); + if (candidate.contentRect !== null && !contentRect) { + return null; + } + + return { + layer: candidate.layer, + measuredAtMs, + viewport: { width: viewportWidth, height: viewportHeight }, + contentRect, + }; +} + +function sanitizeOverlayContentRect( + rect: unknown, +): OverlayContentRect | null { + if (rect === null || rect === undefined) { + return null; + } + + if (!rect || typeof rect !== "object") { + return null; + } + + const candidate = rect as { + x?: unknown; + y?: unknown; + width?: unknown; + height?: unknown; + }; + + const width = readFiniteInRange(candidate.width, 0, MAX_RECT_DIMENSION); + const height = readFiniteInRange(candidate.height, 0, MAX_RECT_DIMENSION); + const x = readFiniteInRange(candidate.x, -MAX_RECT_OFFSET, MAX_RECT_OFFSET); + const y = readFiniteInRange(candidate.y, -MAX_RECT_OFFSET, MAX_RECT_OFFSET); + + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + !Number.isFinite(x) || + !Number.isFinite(y) + ) { + return null; + } + + return { x, y, width, height }; +} + +function readFiniteInRange( + value: unknown, + min: number, + max: number, +): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return Number.NaN; + } + if (value < min || value > max) { + return Number.NaN; + } + return value; +} + +export function createOverlayContentMeasurementStoreService(options?: { + now?: () => number; + warn?: (message: string) => void; +}) { + const now = options?.now ?? (() => Date.now()); + const warn = options?.warn ?? ((message: string) => console.warn(message)); + const latestByLayer: OverlayMeasurementStore = { + visible: null, + invisible: null, + }; + + let droppedInvalid = 0; + let lastInvalidLogAtMs = 0; + + function report(payload: unknown): OverlayContentMeasurement | null { + const nowMs = now(); + const measurement = sanitizeOverlayContentMeasurement(payload, nowMs); + if (!measurement) { + droppedInvalid += 1; + if ( + droppedInvalid > 0 && + nowMs - lastInvalidLogAtMs >= INVALID_LOG_THROTTLE_MS + ) { + warn( + `[overlay-content-bounds] Dropped ${droppedInvalid} invalid measurement payload(s) in the last ${INVALID_LOG_THROTTLE_MS}ms.`, + ); + droppedInvalid = 0; + lastInvalidLogAtMs = nowMs; + } + return null; + } + + latestByLayer[measurement.layer] = measurement; + return measurement; + } + + function getLatestByLayer(layer: OverlayLayer): OverlayContentMeasurement | null { + return latestByLayer[layer]; + } + + return { + getLatestByLayer, + report, + }; +} diff --git a/src/main.ts b/src/main.ts index 6392952..5035771 100644 --- a/src/main.ts +++ b/src/main.ts @@ -101,6 +101,7 @@ import { createFieldGroupingOverlayRuntimeService, createIpcDepsRuntimeService, createNumericShortcutRuntimeService, + createOverlayContentMeasurementStoreService, createOverlayShortcutRuntimeHandlers, createOverlayWindowService, createTokenizerDepsRuntimeService, @@ -291,6 +292,12 @@ let runtimeOptionsManager: RuntimeOptionsManager | null = null; let trackerNotReadyWarningShown = false; let overlayDebugVisualizationEnabled = false; const overlayManager = createOverlayManagerService(); +const overlayContentMeasurementStore = createOverlayContentMeasurementStoreService({ + now: () => Date.now(), + warn: (message: string) => { + console.warn(message); + }, +}); type OverlayHostedModal = "runtime-options" | "subsync"; const restoreVisibleOverlayOnModalClose = new Set(); @@ -1353,6 +1360,9 @@ registerIpcHandlersService( getRuntimeOptions: () => getRuntimeOptionsState(), setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption, cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption, + reportOverlayContentBounds: (payload) => { + overlayContentMeasurementStore.report(payload); + }, }), ); diff --git a/src/preload.ts b/src/preload.ts index 2d9c482..5855712 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -45,6 +45,7 @@ import type { RuntimeOptionState, RuntimeOptionValue, MpvSubtitleRenderMetrics, + OverlayContentMeasurement, } from "./types"; const overlayLayerArg = process.argv.find((arg) => @@ -267,6 +268,9 @@ const electronAPI: ElectronAPI = { notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => { ipcRenderer.send("overlay:modal-closed", modal); }, + reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { + ipcRenderer.send("overlay-content-bounds:report", measurement); + }, }; contextBridge.exposeInMainWorld("electronAPI", electronAPI); diff --git a/src/renderer/overlay-content-measurement.ts b/src/renderer/overlay-content-measurement.ts new file mode 100644 index 0000000..b9fd04a --- /dev/null +++ b/src/renderer/overlay-content-measurement.ts @@ -0,0 +1,113 @@ +import type { OverlayContentMeasurement, OverlayContentRect } from "../types"; +import type { RendererContext } from "./context"; + +const MEASUREMENT_DEBOUNCE_MS = 80; + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function toMeasuredRect(rect: DOMRect): OverlayContentRect | null { + if (!Number.isFinite(rect.left) || !Number.isFinite(rect.top)) { + return null; + } + if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height)) { + return null; + } + + const width = Math.max(0, rect.width); + const height = Math.max(0, rect.height); + + return { + x: round2(rect.left), + y: round2(rect.top), + width: round2(width), + height: round2(height), + }; +} + +function unionRects(a: OverlayContentRect, b: OverlayContentRect): OverlayContentRect { + const left = Math.min(a.x, b.x); + const top = Math.min(a.y, b.y); + const right = Math.max(a.x + a.width, b.x + b.width); + const bottom = Math.max(a.y + a.height, b.y + b.height); + return { + x: round2(left), + y: round2(top), + width: round2(Math.max(0, right - left)), + height: round2(Math.max(0, bottom - top)), + }; +} + +function hasVisibleTextContent(element: HTMLElement): boolean { + return Boolean(element.textContent && element.textContent.trim().length > 0); +} + +function collectContentRect(ctx: RendererContext): OverlayContentRect | null { + let combinedRect: OverlayContentRect | null = null; + + const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot); + if (subtitleHasContent) { + const subtitleRect = toMeasuredRect(ctx.dom.subtitleRoot.getBoundingClientRect()); + if (subtitleRect) { + combinedRect = subtitleRect; + } + } + + const secondaryHasContent = hasVisibleTextContent(ctx.dom.secondarySubRoot); + if (secondaryHasContent) { + const secondaryRect = toMeasuredRect( + ctx.dom.secondarySubContainer.getBoundingClientRect(), + ); + if (secondaryRect) { + combinedRect = combinedRect + ? unionRects(combinedRect, secondaryRect) + : secondaryRect; + } + } + + if (!combinedRect) { + return null; + } + + return { + x: combinedRect.x, + y: combinedRect.y, + width: round2(Math.max(0, combinedRect.width)), + height: round2(Math.max(0, combinedRect.height)), + }; +} + +export function createOverlayContentMeasurementReporter(ctx: RendererContext) { + let debounceTimer: number | null = null; + + function emitNow(): void { + const measurement: OverlayContentMeasurement = { + layer: ctx.platform.overlayLayer, + measuredAtMs: Date.now(), + viewport: { + width: window.innerWidth, + height: window.innerHeight, + }, + // Explicit null rect signals "no content yet", and main should use fallback bounds. + contentRect: collectContentRect(ctx), + }; + + window.electronAPI.reportOverlayContentBounds(measurement); + } + + function schedule(): void { + if (debounceTimer !== null) { + window.clearTimeout(debounceTimer); + } + debounceTimer = window.setTimeout(() => { + debounceTimer = null; + emitNow(); + }, MEASUREMENT_DEBOUNCE_MS); + } + + return { + emitNow, + schedule, + }; +} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index a8f6adc..20128ec 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -32,6 +32,7 @@ import { createKikuModal } from "./modals/kiku.js"; import { createRuntimeOptionsModal } from "./modals/runtime-options.js"; import { createSubsyncModal } from "./modals/subsync.js"; import { createPositioningController } from "./positioning.js"; +import { createOverlayContentMeasurementReporter } from "./overlay-content-measurement.js"; import { createRendererState } from "./state.js"; import { createSubtitleRenderer } from "./subtitle-render.js"; import { resolveRendererDom } from "./utils/dom.js"; @@ -69,6 +70,7 @@ function syncSettingsModalSubtitleSuppression(): void { } const subtitleRenderer = createSubtitleRenderer(ctx); +const measurementReporter = createOverlayContentMeasurementReporter(ctx); const positioning = createPositioningController(ctx, { modalStateReader: { isAnySettingsModalOpen }, applySubtitleFontSize: subtitleRenderer.applySubtitleFontSize, @@ -114,6 +116,7 @@ async function init(): Promise { window.electronAPI.onSubtitle((data: SubtitleData) => { subtitleRenderer.renderSubtitle(data); + measurementReporter.schedule(); }); window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => { @@ -122,11 +125,13 @@ async function init(): Promise { } else { positioning.applyStoredSubtitlePosition(position, "media-change"); } + measurementReporter.schedule(); }); if (ctx.platform.isInvisibleLayer) { window.electronAPI.onMpvSubtitleRenderMetrics((metrics: MpvSubtitleRenderMetrics) => { positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, "event"); + measurementReporter.schedule(); }); window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => { document.body.classList.toggle("debug-invisible-visualization", enabled); @@ -135,16 +140,20 @@ async function init(): Promise { const initialSubtitle = await window.electronAPI.getCurrentSubtitle(); subtitleRenderer.renderSubtitle(initialSubtitle); + measurementReporter.schedule(); window.electronAPI.onSecondarySub((text: string) => { subtitleRenderer.renderSecondarySub(text); + measurementReporter.schedule(); }); window.electronAPI.onSecondarySubMode((mode: SecondarySubMode) => { subtitleRenderer.updateSecondarySubMode(mode); + measurementReporter.schedule(); }); subtitleRenderer.updateSecondarySubMode(await window.electronAPI.getSecondarySubMode()); subtitleRenderer.renderSecondarySub(await window.electronAPI.getCurrentSecondarySub()); + measurementReporter.schedule(); const hoverTarget = ctx.platform.isInvisibleLayer ? ctx.dom.subtitleRoot @@ -159,6 +168,9 @@ async function init(): Promise { mouseHandlers.setupResizeHandler(); mouseHandlers.setupSelectionObserver(); mouseHandlers.setupYomitanObserver(); + window.addEventListener("resize", () => { + measurementReporter.schedule(); + }); jimakuModal.wireDomEvents(); kikuModal.wireDomEvents(); @@ -211,11 +223,14 @@ async function init(): Promise { "startup", ); subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle()); + measurementReporter.schedule(); } if (ctx.platform.shouldToggleMouseIgnore) { window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); } + + measurementReporter.emitNow(); } if (document.readyState === "loading") { diff --git a/src/types.ts b/src/types.ts index 49d225b..ada44b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -465,6 +465,25 @@ export interface MpvSubtitleRenderMetrics { } | null; } +export type OverlayLayer = "visible" | "invisible"; + +export interface OverlayContentRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface OverlayContentMeasurement { + layer: OverlayLayer; + measuredAtMs: number; + viewport: { + width: number; + height: number; + }; + contentRect: OverlayContentRect | null; +} + export interface MecabStatus { available: boolean; enabled: boolean; @@ -611,6 +630,7 @@ export interface ElectronAPI { onOpenRuntimeOptions: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void; notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => void; + reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; } declare global {