@peekling/runtime 0.1.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 (120) hide show
  1. package/AUTHORS +8 -0
  2. package/LICENSE +202 -0
  3. package/LICENSING.md +20 -0
  4. package/NOTICE +6 -0
  5. package/README.md +294 -0
  6. package/dist/configuration-validation.d.ts +19 -0
  7. package/dist/configuration-validation.d.ts.map +1 -0
  8. package/dist/configuration-validation.js +298 -0
  9. package/dist/content.d.ts +44 -0
  10. package/dist/content.d.ts.map +1 -0
  11. package/dist/content.js +738 -0
  12. package/dist/contracts.d.ts +10 -0
  13. package/dist/contracts.d.ts.map +1 -0
  14. package/dist/contracts.js +106 -0
  15. package/dist/defaults.d.ts +7 -0
  16. package/dist/defaults.d.ts.map +1 -0
  17. package/dist/defaults.js +6 -0
  18. package/dist/diagnostics.d.ts +25 -0
  19. package/dist/diagnostics.d.ts.map +1 -0
  20. package/dist/diagnostics.js +122 -0
  21. package/dist/direction.d.ts +3 -0
  22. package/dist/direction.d.ts.map +1 -0
  23. package/dist/direction.js +6 -0
  24. package/dist/errors.d.ts +15 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/errors.js +53 -0
  27. package/dist/events.d.ts +5 -0
  28. package/dist/events.d.ts.map +1 -0
  29. package/dist/events.js +60 -0
  30. package/dist/index.d.ts +6 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +3 -0
  33. package/dist/input.d.ts +31 -0
  34. package/dist/input.d.ts.map +1 -0
  35. package/dist/input.js +215 -0
  36. package/dist/json.d.ts +3 -0
  37. package/dist/json.d.ts.map +1 -0
  38. package/dist/json.js +109 -0
  39. package/dist/loader.d.ts +33 -0
  40. package/dist/loader.d.ts.map +1 -0
  41. package/dist/loader.js +477 -0
  42. package/dist/locomotion.d.ts +8 -0
  43. package/dist/locomotion.d.ts.map +1 -0
  44. package/dist/locomotion.js +29 -0
  45. package/dist/normalized.d.ts +5 -0
  46. package/dist/normalized.d.ts.map +1 -0
  47. package/dist/normalized.js +266 -0
  48. package/dist/overrides.d.ts +29 -0
  49. package/dist/overrides.d.ts.map +1 -0
  50. package/dist/overrides.js +296 -0
  51. package/dist/own-data.d.ts +18 -0
  52. package/dist/own-data.d.ts.map +1 -0
  53. package/dist/own-data.js +205 -0
  54. package/dist/pack-api.d.ts +9 -0
  55. package/dist/pack-api.d.ts.map +1 -0
  56. package/dist/pack-api.js +6 -0
  57. package/dist/pack-shared.d.ts +28 -0
  58. package/dist/pack-shared.d.ts.map +1 -0
  59. package/dist/pack-shared.js +408 -0
  60. package/dist/pack.d.ts +6 -0
  61. package/dist/pack.d.ts.map +1 -0
  62. package/dist/pack.js +316 -0
  63. package/dist/peekling.css +1 -0
  64. package/dist/peekling.css.sri +1 -0
  65. package/dist/peekling.js +7469 -0
  66. package/dist/peekling.js.sri +1 -0
  67. package/dist/peekling.min.js +2 -0
  68. package/dist/peekling.min.js.sri +1 -0
  69. package/dist/plan-compiler.d.ts +82 -0
  70. package/dist/plan-compiler.d.ts.map +1 -0
  71. package/dist/plan-compiler.js +724 -0
  72. package/dist/plan.d.ts +21 -0
  73. package/dist/plan.d.ts.map +1 -0
  74. package/dist/plan.js +287 -0
  75. package/dist/preflight-api.d.ts +3 -0
  76. package/dist/preflight-api.d.ts.map +1 -0
  77. package/dist/preflight-api.js +1 -0
  78. package/dist/preflight.d.ts +22 -0
  79. package/dist/preflight.d.ts.map +1 -0
  80. package/dist/preflight.js +158 -0
  81. package/dist/registry.d.ts +9 -0
  82. package/dist/registry.d.ts.map +1 -0
  83. package/dist/registry.js +24 -0
  84. package/dist/renderer.d.ts +16 -0
  85. package/dist/renderer.d.ts.map +1 -0
  86. package/dist/renderer.js +176 -0
  87. package/dist/resolver.d.ts +3 -0
  88. package/dist/resolver.d.ts.map +1 -0
  89. package/dist/resolver.js +30 -0
  90. package/dist/runtime-diagnostics.d.ts +3 -0
  91. package/dist/runtime-diagnostics.d.ts.map +1 -0
  92. package/dist/runtime-diagnostics.js +5 -0
  93. package/dist/runtime-validation.d.ts +6 -0
  94. package/dist/runtime-validation.d.ts.map +1 -0
  95. package/dist/runtime-validation.js +104 -0
  96. package/dist/runtime.d.ts +87 -0
  97. package/dist/runtime.d.ts.map +1 -0
  98. package/dist/runtime.js +1015 -0
  99. package/dist/sections.d.ts +15 -0
  100. package/dist/sections.d.ts.map +1 -0
  101. package/dist/sections.js +221 -0
  102. package/dist/styles.d.ts +18 -0
  103. package/dist/styles.d.ts.map +1 -0
  104. package/dist/styles.js +132 -0
  105. package/dist/tooling-preflight.d.ts +23 -0
  106. package/dist/tooling-preflight.d.ts.map +1 -0
  107. package/dist/tooling-preflight.js +207 -0
  108. package/dist/tooling-validation.d.ts +8 -0
  109. package/dist/tooling-validation.d.ts.map +1 -0
  110. package/dist/tooling-validation.js +94 -0
  111. package/dist/types.d.ts +403 -0
  112. package/dist/types.d.ts.map +1 -0
  113. package/dist/types.js +29 -0
  114. package/dist/visibility.d.ts +12 -0
  115. package/dist/visibility.d.ts.map +1 -0
  116. package/dist/visibility.js +333 -0
  117. package/dist/web-component.d.ts +31 -0
  118. package/dist/web-component.d.ts.map +1 -0
  119. package/dist/web-component.js +236 -0
  120. package/package.json +74 -0
@@ -0,0 +1,1015 @@
1
+ import { ContentRenderer } from "./content.js";
2
+ import { InputCollector } from "./input.js";
3
+ import { browserCompletionEvent, validateEventPayload } from "./events.js";
4
+ import { directionTo } from "./direction.js";
5
+ import { DiagnosticChannel } from "./diagnostics.js";
6
+ import { EVENT_NAME_PATTERN } from "./contracts.js";
7
+ import { DEFAULT_MAX_DENSITY, DEFAULT_POSITION } from "./defaults.js";
8
+ import { animationCycleMs, locomotionAdvance, locomotionSample, } from "./locomotion.js";
9
+ import { loadNativePack, requiredAtlasDensity, } from "./loader.js";
10
+ import { resolveCapabilityName } from "./normalized.js";
11
+ import { PlanRuntime, compiledPlanSectionRequirements, createDefaultPlan, } from "./plan.js";
12
+ import { compilePlan } from "./plan-compiler.js";
13
+ import { validateRuntimeConfiguration, validRuntimeName, } from "./runtime-validation.js";
14
+ import { runtimeMessage } from "./runtime-diagnostics.js";
15
+ import { OverrideManager, rejectedOverrideHandle } from "./overrides.js";
16
+ import { AtlasRenderer } from "./renderer.js";
17
+ import { resolveState } from "./resolver.js";
18
+ import { SectionTracker } from "./sections.js";
19
+ import { registerSiteVisibility } from "./visibility.js";
20
+ import { snapshotConfiguration } from "./own-data.js";
21
+ import { NATIVE_DIRECTIONS, } from "./types.js";
22
+ const SUSPEND_HOST = 1;
23
+ const SUSPEND_PAGE = 2;
24
+ const SUSPEND_SITE = 4;
25
+ const MAX_TIMER_DELAY = 2_147_483_647;
26
+ const EMPTY_CONTENT_SELECTIONS = Object.freeze([]);
27
+ function browserSelectorIsValid(document, selector) {
28
+ try {
29
+ document.createDocumentFragment().querySelector(selector);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ function runtimeNoticeDetail(code) {
37
+ switch (code) {
38
+ case "config.invalid-name":
39
+ return "Web Component name was ignored because it must be true, false, or short plain text";
40
+ case "lifecycle.hidden":
41
+ return "Page suspended";
42
+ case "lifecycle.visible":
43
+ return "Page resumed";
44
+ case "event.coalesced":
45
+ return "Host event was coalesced";
46
+ case "event.accepted":
47
+ return "Host event was accepted";
48
+ case "override.not-ready":
49
+ return "Override rejected before ready";
50
+ case "lifecycle.paused":
51
+ return "Instance paused";
52
+ case "lifecycle.resumed":
53
+ return "Instance resumed";
54
+ case "lifecycle.destroyed":
55
+ return "Instance destroyed";
56
+ case "config.pack-load-failed":
57
+ return "Pack load failed";
58
+ case "lifecycle.reduced-motion-enabled":
59
+ return "Reduced motion enabled";
60
+ case "lifecycle.reduced-motion-disabled":
61
+ return "Reduced motion disabled";
62
+ case "config.styles-load-failed":
63
+ return "Runtime stylesheet failed";
64
+ case "lifecycle.dismissed":
65
+ return "Instance dismissed";
66
+ case "lifecycle.shown":
67
+ return "Instance shown";
68
+ case "internal.frame-failed":
69
+ return "Frame failed and the instance was destroyed";
70
+ case "render.atlas-upgrade-failed":
71
+ return "Atlas upgrade failed";
72
+ case "lifecycle.pagehide":
73
+ return "Page lifetime ended";
74
+ default:
75
+ return code;
76
+ }
77
+ }
78
+ function hatchOptions(input) {
79
+ if (input === undefined)
80
+ return {};
81
+ if (typeof input === "string") {
82
+ return { character: input };
83
+ }
84
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
85
+ throw new TypeError(runtimeMessage("options.input", "Expected character or options"));
86
+ }
87
+ return input;
88
+ }
89
+ /** Package-internal implementation. It is not a supported import. */
90
+ export class PeeklingRuntime {
91
+ static hatch(input) {
92
+ return new PeeklingRuntime(hatchOptions(input));
93
+ }
94
+ /** Package-internal presentation update used only by the Web Component. */
95
+ updateComponentName(value) {
96
+ if (this.#destroyed) {
97
+ return false;
98
+ }
99
+ if (!validRuntimeName(value)) {
100
+ this.#notice("config.invalid-name", "warning", "config");
101
+ return true;
102
+ }
103
+ this.#displayName =
104
+ typeof value === "string"
105
+ ? value
106
+ : value === true
107
+ ? (this.#loaded?.content.displayName ?? "")
108
+ : undefined;
109
+ if (this.#loaded && this.#displayName && !this.#contentRenderer) {
110
+ const renderer = this.#ensureContentRenderer();
111
+ void renderer.ready.then(() => this.#wake(), () => {
112
+ if (this.#destroyed)
113
+ return;
114
+ this.#diagnoseStyleFailure();
115
+ this.#destroy("failed");
116
+ });
117
+ }
118
+ else {
119
+ this.#wake();
120
+ }
121
+ return true;
122
+ }
123
+ ready;
124
+ finished;
125
+ #resolveFinished;
126
+ #destroyed = false;
127
+ #abort = new AbortController();
128
+ #loaded;
129
+ #renderer;
130
+ #contentRenderer;
131
+ #sections;
132
+ #input;
133
+ #raf = 0;
134
+ #planTimer = 0;
135
+ #frameGeneration = 0;
136
+ #lastTick = 0;
137
+ #animationClock = 0;
138
+ #locomotionElapsed = 0;
139
+ #locomotionState = "";
140
+ #position = { x: 0, y: 0 };
141
+ #seenReaction = 0;
142
+ #reducedMotion = false;
143
+ #media;
144
+ #mediaListener;
145
+ #dprMedia;
146
+ #dprListener;
147
+ #densityUpgrade;
148
+ #siteHidden = false;
149
+ #suspensions = 0;
150
+ #suspendedAt;
151
+ #unregisterVisibility;
152
+ #visibilityReady;
153
+ #pageVisibilityListener;
154
+ #pageHideListener;
155
+ #maxDensity = DEFAULT_MAX_DENSITY;
156
+ #displayName;
157
+ #document;
158
+ #window;
159
+ #plan;
160
+ #content;
161
+ #options;
162
+ #now;
163
+ #overrides;
164
+ #instanceId;
165
+ #diagnostics;
166
+ #styles;
167
+ #rejectedOverrideId = 0;
168
+ #reportedNotReadyEvent = false;
169
+ #reportedNotReadyOverride = false;
170
+ constructor(options = {}) {
171
+ options = snapshotConfiguration(options);
172
+ const document = options.document ?? globalThis.document;
173
+ const window = options.window ?? globalThis.window;
174
+ if (!document || !window)
175
+ throw new Error(runtimeMessage("browser.context", "Browser document/window required"));
176
+ this.#document = document;
177
+ this.#window = window;
178
+ this.#options = options;
179
+ this.#now = window.performance?.now?.bind(window.performance) ?? Date.now;
180
+ this.#instanceId = `instance:${Math.floor(this.#now())}:${Math.random().toString(36).slice(2, 8)}`;
181
+ this.#diagnostics = new DiagnosticChannel({
182
+ instanceId: this.#instanceId,
183
+ now: this.#now,
184
+ ...(options.logger ? { logger: options.logger } : {}),
185
+ ...(options.onDiagnostic ? { messageSink: options.onDiagnostic } : {}),
186
+ ...(options.diagnostics?.context
187
+ ? { context: options.diagnostics.context }
188
+ : {}),
189
+ ...(options.diagnostics?.debug ? { debug: true } : {}),
190
+ ...(options.diagnostics?.console === undefined
191
+ ? {}
192
+ : { console: options.diagnostics.console }),
193
+ });
194
+ const [content, styles] = validateRuntimeConfiguration(options, window.location?.href ?? document.baseURI);
195
+ this.#styles = styles;
196
+ this.finished = new Promise((resolve) => {
197
+ this.#resolveFinished = resolve;
198
+ });
199
+ this.#displayName =
200
+ typeof options.name === "string"
201
+ ? options.name
202
+ : options.name === true
203
+ ? ""
204
+ : undefined;
205
+ this.#content = content;
206
+ const visibility = registerSiteVisibility(document, window, (hidden) => {
207
+ if (hidden !== null) {
208
+ this.#setSiteHidden(hidden);
209
+ return;
210
+ }
211
+ this.#diagnose(runtimeMessage("visibility.root-failed", "Visibility root failed and the instance was destroyed"), "internal.frame-failed", "error", "internal");
212
+ this.#destroy("failed");
213
+ }, this.#styles);
214
+ this.#unregisterVisibility = visibility.unregister;
215
+ this.#visibilityReady = visibility.ready;
216
+ this.#pageVisibilityListener = () => {
217
+ if (document.hidden) {
218
+ this.#setSuspended(SUSPEND_PAGE, true);
219
+ this.#notice("lifecycle.hidden", "info", "lifecycle");
220
+ }
221
+ else {
222
+ this.#setSuspended(SUSPEND_PAGE, false);
223
+ this.#notice("lifecycle.visible", "info", "lifecycle");
224
+ }
225
+ };
226
+ document.addEventListener("visibilitychange", this.#pageVisibilityListener, { passive: true });
227
+ this.#pageHideListener = () => this.#endPage();
228
+ window.addEventListener("pagehide", this.#pageHideListener, {
229
+ passive: true,
230
+ });
231
+ if (document.hidden)
232
+ this.#setSuspended(SUSPEND_PAGE, true);
233
+ this.ready = this.#initialize();
234
+ void this.ready.catch(() => { });
235
+ }
236
+ emit(name, payload) {
237
+ if (this.#destroyed)
238
+ return this.#rejectEvent("destroyed");
239
+ if (this.#siteHidden)
240
+ return this.#rejectEvent("hidden");
241
+ if (typeof name !== "string" || !EVENT_NAME_PATTERN.test(name)) {
242
+ return this.#rejectEvent("invalid-name");
243
+ }
244
+ if (!this.#input)
245
+ return this.#rejectEvent("not-ready");
246
+ let checked;
247
+ try {
248
+ checked = validateEventPayload(payload);
249
+ }
250
+ catch {
251
+ return this.#rejectEvent("invalid-payload");
252
+ }
253
+ const result = this.#input.captureReaction(name, checked, "application");
254
+ if (!result.accepted)
255
+ return this.#rejectEvent(result.reason);
256
+ this.#notice(result.coalesced ? "event.coalesced" : "event.accepted", "info", "event", result.id);
257
+ return result;
258
+ }
259
+ override(value) {
260
+ const rejected = (reason) => rejectedOverrideHandle(`override:rejected:${++this.#rejectedOverrideId}`, reason);
261
+ if (this.#destroyed)
262
+ return rejected("destroyed");
263
+ if (this.#siteHidden)
264
+ return rejected("dismissed");
265
+ if (!this.#overrides) {
266
+ if (!this.#reportedNotReadyOverride) {
267
+ this.#reportedNotReadyOverride = true;
268
+ this.#notice("override.not-ready", "warning", "override");
269
+ }
270
+ return rejected("rejected");
271
+ }
272
+ const handle = this.#overrides.add(value);
273
+ const completion = this.#overrides.browserCompletionFor(handle.id);
274
+ const releaseCompletion = completion
275
+ ? this.#input?.retainEvent(completion)
276
+ : undefined;
277
+ void handle.finished.then(() => {
278
+ releaseCompletion?.();
279
+ this.#wake();
280
+ });
281
+ this.#wake();
282
+ return handle;
283
+ }
284
+ pause() {
285
+ if (this.#destroyed || this.#suspensions & SUSPEND_HOST)
286
+ return;
287
+ this.#setSuspended(SUSPEND_HOST, true);
288
+ this.#notice("lifecycle.paused", "info", "lifecycle");
289
+ }
290
+ resume() {
291
+ if (this.#destroyed || !(this.#suspensions & SUSPEND_HOST))
292
+ return;
293
+ this.#setSuspended(SUSPEND_HOST, false);
294
+ this.#notice("lifecycle.resumed", "info", "lifecycle");
295
+ }
296
+ destroy() {
297
+ this.#destroy("destroyed");
298
+ }
299
+ #destroy(reason) {
300
+ if (this.#destroyed)
301
+ return;
302
+ this.#destroyed = true;
303
+ this.#frameGeneration += 1;
304
+ this.#overrides?.clear("destroyed");
305
+ this.#abort.abort();
306
+ this.#cancelWork();
307
+ this.#input?.destroy();
308
+ this.#plan?.reset();
309
+ this.#sections?.destroy();
310
+ this.#contentRenderer?.destroy();
311
+ this.#renderer?.destroy();
312
+ this.#unregisterVisibility?.();
313
+ this.#unregisterVisibility = undefined;
314
+ if (this.#pageVisibilityListener) {
315
+ this.#document.removeEventListener("visibilitychange", this.#pageVisibilityListener);
316
+ this.#pageVisibilityListener = undefined;
317
+ }
318
+ if (this.#pageHideListener) {
319
+ this.#window.removeEventListener("pagehide", this.#pageHideListener);
320
+ this.#pageHideListener = undefined;
321
+ }
322
+ this.#loaded?.release();
323
+ if (this.#media && this.#mediaListener) {
324
+ this.#media.removeEventListener("change", this.#mediaListener);
325
+ }
326
+ if (this.#dprMedia && this.#dprListener) {
327
+ this.#dprMedia.removeEventListener("change", this.#dprListener);
328
+ }
329
+ this.#notice("lifecycle.destroyed", "info", "lifecycle");
330
+ const resolve = this.#resolveFinished;
331
+ this.#resolveFinished = undefined;
332
+ resolve?.({ reason });
333
+ }
334
+ async #initialize() {
335
+ try {
336
+ const validateSelector = (selector) => browserSelectorIsValid(this.#document, selector);
337
+ if (this.#options.plan !== undefined) {
338
+ compilePlan(this.#options.plan, {
339
+ contentIds: new Set(Object.keys(this.#content)),
340
+ validateSelector,
341
+ });
342
+ }
343
+ const browserWindow = this.#window;
344
+ const connection = browserWindow.navigator.connection;
345
+ this.#maxDensity =
346
+ this.#options.maxDensity ?? (connection?.saveData ? 2 : 4);
347
+ let loaded;
348
+ try {
349
+ loaded = await loadNativePack({
350
+ ...(this.#options.character === undefined
351
+ ? {}
352
+ : { character: this.#options.character }),
353
+ baseUrl: this.#window.location.href,
354
+ ...(this.#options.pack === undefined
355
+ ? {}
356
+ : { pack: this.#options.pack }),
357
+ ...(this.#options.packUrl === undefined
358
+ ? {}
359
+ : { packUrl: this.#options.packUrl }),
360
+ ...(this.#options.atlasUrl === undefined
361
+ ? {}
362
+ : { atlasUrl: this.#options.atlasUrl }),
363
+ ...(this.#options.scale === undefined
364
+ ? {}
365
+ : { renderScale: this.#options.scale }),
366
+ devicePixelRatio: this.#window.devicePixelRatio,
367
+ ...(this.#options.density === undefined
368
+ ? {}
369
+ : { densityOverride: this.#options.density }),
370
+ maxDensity: this.#maxDensity,
371
+ fetch: this.#window.fetch.bind(this.#window),
372
+ image: () => new browserWindow.Image(),
373
+ signal: this.#abort.signal,
374
+ createObjectURL: browserWindow.URL.createObjectURL.bind(browserWindow.URL),
375
+ revokeObjectURL: browserWindow.URL.revokeObjectURL.bind(browserWindow.URL),
376
+ diagnostic: (message, category) => {
377
+ this.#diagnose(message, category ? `config.pack-load-${category}` : "config.pack-load", "warning", "config");
378
+ },
379
+ });
380
+ }
381
+ catch (cause) {
382
+ if (this.#destroyed && this.#abort.signal.aborted)
383
+ throw cause;
384
+ this.#notice("config.pack-load-failed", "error", "config", undefined, undefined, cause);
385
+ throw cause;
386
+ }
387
+ if (this.#destroyed) {
388
+ loaded.release();
389
+ throw new DOMException(runtimeMessage("lifecycle.destroyed", "Destroyed while initializing"), "AbortError");
390
+ }
391
+ const scale = this.#options.scale ?? loaded.content.defaultScale;
392
+ if (!Number.isInteger(scale) || scale < 1 || scale > 4) {
393
+ loaded.release();
394
+ throw new TypeError(runtimeMessage("scale.value", "scale must be integer 1-4"));
395
+ }
396
+ this.#loaded = loaded;
397
+ const locomotion = hasLocomotion(loaded.content);
398
+ const capabilities = new Set(locomotion ? ["locomotion"] : []);
399
+ const compiled = compilePlan(this.#options.plan ?? createDefaultPlan(locomotion), {
400
+ states: new Set(Object.keys(loaded.content.states)),
401
+ capabilities,
402
+ contentIds: new Set(Object.keys(this.#content)),
403
+ validateSelector,
404
+ });
405
+ this.#plan = new PlanRuntime(compiled);
406
+ this.#overrides = new OverrideManager({
407
+ states: new Set(Object.keys(loaded.content.states)),
408
+ capabilities,
409
+ contentIds: new Set(Object.keys(this.#content)),
410
+ }, this.#now, (message) => this.#diagnose(message, "override.rejected", "warning", "override"));
411
+ if (this.#displayName === "") {
412
+ this.#displayName = loaded.content.displayName;
413
+ }
414
+ this.#media = this.#window.matchMedia("(prefers-reduced-motion: reduce)");
415
+ this.#reducedMotion = this.#media.matches;
416
+ this.#mediaListener = (event) => {
417
+ this.#reducedMotion = event.matches;
418
+ this.#notice(event.matches
419
+ ? "lifecycle.reduced-motion-enabled"
420
+ : "lifecycle.reduced-motion-disabled", "info", "lifecycle");
421
+ this.#wake();
422
+ };
423
+ this.#media.addEventListener("change", this.#mediaListener);
424
+ this.#watchDpr();
425
+ this.#position = resolveInitialPosition(this.#options.position ?? DEFAULT_POSITION, this.#window.innerWidth, this.#window.innerHeight, (loaded.content.atlas.logicalWidth ?? loaded.content.atlas.cellWidth) *
426
+ scale, (loaded.content.atlas.logicalHeight ??
427
+ loaded.content.atlas.cellHeight) * scale);
428
+ this.#renderer = new AtlasRenderer(this.#document, loaded.content, loaded.atlasObjectUrl, scale, this.#styles);
429
+ if (Object.keys(this.#content).length ||
430
+ this.#displayName !== undefined) {
431
+ this.#ensureContentRenderer();
432
+ }
433
+ try {
434
+ await Promise.all([
435
+ this.#visibilityReady,
436
+ this.#renderer.ready,
437
+ this.#contentRenderer?.ready,
438
+ ]);
439
+ }
440
+ catch (cause) {
441
+ this.#diagnoseStyleFailure(cause);
442
+ throw cause;
443
+ }
444
+ const requirements = compiledPlanSectionRequirements(compiled);
445
+ this.#createInput();
446
+ if (requirements.selectors.length) {
447
+ this.#sections = new SectionTracker(this.#document, this.#window, requirements.selectors, requirements.thresholds, () => this.#wake(), (fact) => this.#input?.captureReaction("section.visibility", fact, "browser"));
448
+ }
449
+ this.#renderer.setHidden(this.#siteHidden);
450
+ if (this.#suspensions) {
451
+ this.#input?.setSuspended(true);
452
+ this.#sections?.setSuspended(true);
453
+ }
454
+ this.#lastTick = this.#now();
455
+ this.#tick(this.#lastTick, true);
456
+ return this;
457
+ }
458
+ catch (error) {
459
+ this.#destroy("failed");
460
+ throw error;
461
+ }
462
+ }
463
+ #createInput() {
464
+ if (this.#destroyed || this.#input || !this.#plan)
465
+ return;
466
+ const events = new Set();
467
+ const coalesce = new Map();
468
+ for (const rule of this.#plan.plan.rules) {
469
+ const browser = rule.when.source === "browser";
470
+ if (browser)
471
+ events.add(rule.when.event);
472
+ if (rule.effect.until?.type === "event") {
473
+ const completion = browserCompletionEvent(rule.effect.until.name);
474
+ if (completion)
475
+ events.add(completion);
476
+ }
477
+ if (rule.when.coalesce !== "latest")
478
+ continue;
479
+ const key = `${rule.when.source}:${rule.when.event}`;
480
+ if (browser)
481
+ coalesce.set(key, true);
482
+ else {
483
+ const ordering = rule.effect.surfaces[0].ordering;
484
+ coalesce.set(key, {
485
+ sessionField: ordering.sessionField,
486
+ revisionField: ordering.revisionField,
487
+ });
488
+ }
489
+ }
490
+ this.#input = new InputCollector(this.#document, this.#window, {
491
+ events,
492
+ coalesce,
493
+ diagnostic: (message) => this.#diagnose(message, "event.queue-overflow", "warning", "event"),
494
+ now: this.#now,
495
+ wake: () => this.#wake(),
496
+ });
497
+ if (events.has("page.lifecycle")) {
498
+ this.#input.captureReaction("page.lifecycle", { phase: "show" }, "browser");
499
+ }
500
+ }
501
+ #ensureContentRenderer() {
502
+ if (this.#contentRenderer || this.#destroyed)
503
+ return this.#contentRenderer;
504
+ this.#contentRenderer = new ContentRenderer(this.#document, {
505
+ diagnostic: (message) => this.#diagnose(message, "render.host-failed", "error", "render"),
506
+ ...(this.#options.bindings ? { bindings: this.#options.bindings } : {}),
507
+ ...(this.#options.theme ? { theme: this.#options.theme } : {}),
508
+ ...(this.#options.accessibility?.announceContent
509
+ ? { announce: true }
510
+ : {}),
511
+ ...(this.#options.accessibility?.contentLabel
512
+ ? { label: this.#options.accessibility.contentLabel }
513
+ : {}),
514
+ emit: (name, payload) => this.emit(name, payload),
515
+ wake: () => this.#wake(),
516
+ styles: this.#styles,
517
+ });
518
+ return this.#contentRenderer;
519
+ }
520
+ #diagnose(message, code = "runtime.notice", severity = "warning", phase = "internal", eventId, metadata, cause) {
521
+ this.#diagnostics.emit({
522
+ code,
523
+ severity,
524
+ message,
525
+ phase,
526
+ ...(eventId === undefined ? {} : { eventId }),
527
+ ...(metadata ? { metadata } : {}),
528
+ ...(cause ? { cause } : {}),
529
+ });
530
+ }
531
+ #notice(code, severity = "warning", phase = "internal", eventId, metadata, cause) {
532
+ this.#diagnose(runtimeMessage(code, () => runtimeNoticeDetail(code)), code, severity, phase, eventId, metadata, cause);
533
+ }
534
+ #diagnoseStyleFailure(cause) {
535
+ this.#notice("config.styles-load-failed", "error", "config", undefined, undefined, cause);
536
+ }
537
+ #rejectEvent(reason) {
538
+ if (reason === "not-ready") {
539
+ if (this.#reportedNotReadyEvent)
540
+ return { accepted: false, reason };
541
+ this.#reportedNotReadyEvent = true;
542
+ }
543
+ this.#diagnose(runtimeMessage("event.rejected", "Host event was rejected"), `event.rejected.${reason}`, "warning", "event", undefined, { reason });
544
+ return { accepted: false, reason };
545
+ }
546
+ #suspendClock() {
547
+ if (this.#suspendedAt !== undefined)
548
+ return;
549
+ this.#suspendedAt = this.#now();
550
+ this.#cancelWork();
551
+ this.#input?.setSuspended(true);
552
+ this.#sections?.setSuspended(true);
553
+ }
554
+ #cancelWork() {
555
+ if (this.#raf)
556
+ this.#window.cancelAnimationFrame(this.#raf);
557
+ this.#raf = 0;
558
+ if (this.#planTimer)
559
+ this.#window.clearTimeout(this.#planTimer);
560
+ this.#planTimer = 0;
561
+ }
562
+ #resumeClock() {
563
+ if (this.#suspendedAt === undefined || this.#suspensions || this.#destroyed)
564
+ return;
565
+ const now = this.#now();
566
+ const delta = Math.max(0, now - this.#suspendedAt);
567
+ this.#suspendedAt = undefined;
568
+ this.#overrides?.rebase(delta);
569
+ this.#plan?.rebase(delta);
570
+ this.#input?.setSuspended(false);
571
+ this.#sections?.setSuspended(false);
572
+ this.#lastTick = now;
573
+ this.#wake();
574
+ }
575
+ #setSuspended(reason, suspended) {
576
+ const previous = this.#suspensions;
577
+ if (suspended)
578
+ this.#suspensions |= reason;
579
+ else
580
+ this.#suspensions &= ~reason;
581
+ if (previous === this.#suspensions)
582
+ return;
583
+ this.#frameGeneration += 1;
584
+ if (!previous && this.#suspensions)
585
+ this.#suspendClock();
586
+ if (previous && !this.#suspensions)
587
+ this.#resumeClock();
588
+ }
589
+ #setSiteHidden(hidden) {
590
+ if (hidden === this.#siteHidden)
591
+ return;
592
+ this.#siteHidden = hidden;
593
+ this.#renderer?.setHidden(hidden);
594
+ if (this.#destroyed)
595
+ return;
596
+ if (hidden && this.#contentRenderer) {
597
+ this.#contentRenderer.renderSurfaces([], {}, this.#position, {
598
+ width: this.#window.innerWidth,
599
+ height: this.#window.innerHeight,
600
+ }, { width: 0, height: 0 });
601
+ if (this.#destroyed)
602
+ return;
603
+ }
604
+ if (hidden) {
605
+ this.#overrides?.clear("dismissed");
606
+ this.#setSuspended(SUSPEND_SITE, true);
607
+ this.#notice("lifecycle.dismissed", "info", "lifecycle");
608
+ return;
609
+ }
610
+ this.#setSuspended(SUSPEND_SITE, false);
611
+ if (!this.#renderer || !this.#loaded || this.#destroyed)
612
+ return;
613
+ this.#notice("lifecycle.shown", "info", "lifecycle");
614
+ if (!this.#destroyed)
615
+ this.#wake();
616
+ }
617
+ #wake() {
618
+ if (this.#destroyed || this.#suspensions || !this.#renderer || this.#raf)
619
+ return;
620
+ if (this.#planTimer)
621
+ this.#window.clearTimeout(this.#planTimer);
622
+ this.#planTimer = 0;
623
+ const generation = this.#frameGeneration;
624
+ const frame = this.#window.requestAnimationFrame(() => {
625
+ this.#raf = 0;
626
+ if (this.#destroyed ||
627
+ this.#suspensions ||
628
+ generation !== this.#frameGeneration)
629
+ return;
630
+ try {
631
+ this.#tick(this.#now());
632
+ }
633
+ catch (cause) {
634
+ this.#notice("internal.frame-failed", "error", "internal", undefined, undefined, cause);
635
+ this.#destroy("failed");
636
+ }
637
+ });
638
+ if (this.#destroyed ||
639
+ this.#suspensions ||
640
+ generation !== this.#frameGeneration) {
641
+ this.#window.cancelAnimationFrame(frame);
642
+ return;
643
+ }
644
+ this.#raf = frame;
645
+ }
646
+ #schedulePlan(frame) {
647
+ if (!this.#frameActive(frame))
648
+ return;
649
+ if (this.#planTimer)
650
+ this.#window.clearTimeout(this.#planTimer);
651
+ this.#planTimer = 0;
652
+ const overrideCandidate = frame.overrides.nextWakeAt();
653
+ const overrideWake = Number.isFinite(overrideCandidate)
654
+ ? overrideCandidate
655
+ : undefined;
656
+ const interruptCandidate = frame.plan.nextWakeAt();
657
+ const interruptWake = Number.isFinite(interruptCandidate)
658
+ ? interruptCandidate
659
+ : undefined;
660
+ const animationCandidate = this.#reducedMotion
661
+ ? undefined
662
+ : frame.renderer.nextFrameIn(this.#animationClock);
663
+ const animationDelay = Number.isFinite(animationCandidate)
664
+ ? animationCandidate
665
+ : undefined;
666
+ const animationWake = animationDelay === undefined ? undefined : frame.now + animationDelay;
667
+ const runtimeWake = overrideWake === undefined
668
+ ? interruptWake
669
+ : interruptWake === undefined
670
+ ? overrideWake
671
+ : Math.min(overrideWake, interruptWake);
672
+ const wakeAt = runtimeWake === undefined
673
+ ? animationWake
674
+ : animationWake === undefined
675
+ ? runtimeWake
676
+ : Math.min(runtimeWake, animationWake);
677
+ if (wakeAt === undefined ||
678
+ !Number.isFinite(wakeAt) ||
679
+ !this.#frameActive(frame))
680
+ return;
681
+ const timer = this.#window.setTimeout(() => {
682
+ if (!this.#frameActive(frame))
683
+ return;
684
+ this.#planTimer = 0;
685
+ this.#wake();
686
+ }, Math.min(MAX_TIMER_DELAY, Math.max(0, wakeAt - frame.now)));
687
+ if (!this.#frameActive(frame)) {
688
+ this.#window.clearTimeout(timer);
689
+ return;
690
+ }
691
+ this.#planTimer = timer;
692
+ }
693
+ #watchDpr() {
694
+ if (this.#dprMedia && this.#dprListener)
695
+ this.#dprMedia.removeEventListener("change", this.#dprListener);
696
+ this.#dprMedia = this.#window.matchMedia(`(resolution: ${this.#window.devicePixelRatio}dppx)`);
697
+ this.#dprListener = () => {
698
+ this.#watchDpr();
699
+ this.#maybeUpgradeDensity();
700
+ this.#wake();
701
+ };
702
+ this.#dprMedia.addEventListener("change", this.#dprListener);
703
+ }
704
+ #maybeUpgradeDensity() {
705
+ if (this.#destroyed ||
706
+ !this.#loaded ||
707
+ !this.#renderer ||
708
+ this.#densityUpgrade ||
709
+ this.#options.density !== undefined)
710
+ return;
711
+ const scale = this.#options.scale ?? this.#loaded.content.defaultScale;
712
+ const required = requiredAtlasDensity(this.#window.devicePixelRatio, scale, this.#maxDensity);
713
+ if (required <= this.#loaded.loadedDensity)
714
+ return;
715
+ const loaded = this.#loaded;
716
+ this.#densityUpgrade = loaded
717
+ .upgrade(required)
718
+ .then((changed) => {
719
+ if (changed && !this.#destroyed && this.#loaded === loaded) {
720
+ this.#renderer?.swapAtlas(loaded.content, loaded.atlasObjectUrl);
721
+ this.#wake();
722
+ }
723
+ })
724
+ .catch((error) => {
725
+ if (!this.#destroyed)
726
+ this.#notice("render.atlas-upgrade-failed", "warning", "render", undefined, undefined, error);
727
+ })
728
+ .finally(() => {
729
+ this.#densityUpgrade = undefined;
730
+ });
731
+ }
732
+ #frameActive(frame) {
733
+ return (!this.#destroyed &&
734
+ !this.#suspensions &&
735
+ frame.generation === this.#frameGeneration &&
736
+ frame.renderer === this.#renderer &&
737
+ frame.loaded === this.#loaded &&
738
+ frame.input === this.#input &&
739
+ frame.plan === this.#plan &&
740
+ frame.overrides === this.#overrides);
741
+ }
742
+ #beginFrame(now, force) {
743
+ const renderer = this.#renderer;
744
+ const loaded = this.#loaded;
745
+ const input = this.#input;
746
+ const plan = this.#plan;
747
+ const overrides = this.#overrides;
748
+ if (this.#destroyed ||
749
+ this.#suspensions ||
750
+ !renderer ||
751
+ !loaded ||
752
+ !input ||
753
+ !plan ||
754
+ !overrides)
755
+ return;
756
+ const elapsed = now - this.#lastTick;
757
+ if (!force && elapsed < 1000 / 30) {
758
+ this.#wake();
759
+ return;
760
+ }
761
+ return {
762
+ generation: this.#frameGeneration,
763
+ now,
764
+ elapsed,
765
+ stepMs: Math.min(Math.max(0, elapsed) || 1000 / 30, 100),
766
+ renderer,
767
+ loaded,
768
+ input,
769
+ plan,
770
+ overrides,
771
+ };
772
+ }
773
+ #snapshotFrame(frame) {
774
+ const loaded = frame.loaded.content;
775
+ const input = frame.input;
776
+ const scale = this.#options.scale ?? loaded.defaultScale;
777
+ const viewport = {
778
+ width: this.#window.innerWidth,
779
+ height: this.#window.innerHeight,
780
+ };
781
+ const characterSize = {
782
+ width: (loaded.atlas.logicalWidth ?? loaded.atlas.cellWidth) * scale,
783
+ height: (loaded.atlas.logicalHeight ?? loaded.atlas.cellHeight) * scale,
784
+ };
785
+ const devicePixelRatio = this.#window.devicePixelRatio;
786
+ this.#maybeUpgradeDensity();
787
+ this.#position = clampPosition(this.#position, viewport, characterSize.width, characterSize.height);
788
+ const sections = this.#sections?.snapshot();
789
+ const reactionCandidate = input.reaction;
790
+ const reaction = reactionCandidate && reactionCandidate.id > this.#seenReaction
791
+ ? reactionCandidate
792
+ : undefined;
793
+ const world = {
794
+ now: frame.now,
795
+ pointer: input.pointer,
796
+ position: this.#position,
797
+ viewport,
798
+ lastActivityAt: input.lastActivityAt,
799
+ reducedMotion: this.#reducedMotion,
800
+ pageVisible: !this.#document.hidden,
801
+ sections,
802
+ reaction,
803
+ };
804
+ return {
805
+ frame,
806
+ devicePixelRatio,
807
+ characterSize,
808
+ world,
809
+ reaction,
810
+ };
811
+ }
812
+ #evaluateFrame(snapshot) {
813
+ const { frame, world, reaction } = snapshot;
814
+ const { plan, overrides } = frame;
815
+ const ordinary = plan.evaluate(world);
816
+ const eventStatus = reaction ? plan.lastEventStatus : undefined;
817
+ const eventRejections = reaction ? plan.lastEventRejections : [];
818
+ if (reaction &&
819
+ eventStatus !== "invalid" &&
820
+ eventStatus !== "stale" &&
821
+ eventStatus !== "closed") {
822
+ overrides.observeEvent(reaction.name, reaction.source);
823
+ }
824
+ const request = overrides.compose(ordinary, world);
825
+ if (this.#reducedMotion) {
826
+ delete request.motion;
827
+ if (request.capability === "locomotion")
828
+ delete request.capability;
829
+ if (!request.state)
830
+ request.state = "idle";
831
+ }
832
+ const diagnostics = [];
833
+ if (reaction) {
834
+ this.#seenReaction = reaction.id;
835
+ frame.input.consumeReaction(reaction.id);
836
+ if (eventRejections.length) {
837
+ for (const rejection of eventRejections) {
838
+ diagnostics.push(Object.freeze({
839
+ message: runtimeMessage(`event.${rejection.status}`, () => `Host event ${rejection.status}`),
840
+ code: `event.${rejection.status}`,
841
+ severity: rejection.status === "invalid" ? "warning" : "info",
842
+ eventId: reaction.id,
843
+ metadata: Object.freeze({ channel: rejection.channel }),
844
+ }));
845
+ }
846
+ }
847
+ else if (eventStatus === "stale" ||
848
+ eventStatus === "closed" ||
849
+ eventStatus === "invalid") {
850
+ diagnostics.push(Object.freeze({
851
+ message: runtimeMessage(`event.${eventStatus}`, () => `Host event ${eventStatus}`),
852
+ code: `event.${eventStatus}`,
853
+ severity: eventStatus === "invalid" ? "warning" : "info",
854
+ eventId: reaction.id,
855
+ }));
856
+ }
857
+ else if (eventStatus !== "matched") {
858
+ diagnostics.push(Object.freeze({
859
+ message: runtimeMessage("event.unmatched", "Host event unmatched"),
860
+ code: "event.unmatched",
861
+ severity: "info",
862
+ eventId: reaction.id,
863
+ }));
864
+ }
865
+ }
866
+ return { request, diagnostics };
867
+ }
868
+ #emitFrameDiagnostics(frame, diagnostics) {
869
+ for (const diagnostic of diagnostics) {
870
+ if (!this.#frameActive(frame))
871
+ return false;
872
+ this.#diagnose(diagnostic.message, diagnostic.code, diagnostic.severity, "event", diagnostic.eventId, diagnostic.metadata);
873
+ }
874
+ return this.#frameActive(frame);
875
+ }
876
+ #calculatePresentation(snapshot, evaluation) {
877
+ const { frame, characterSize, world } = snapshot;
878
+ const { request } = evaluation;
879
+ const loaded = frame.loaded.content;
880
+ if (!this.#reducedMotion)
881
+ this.#animationClock += Math.max(0, frame.elapsed);
882
+ const requested = request.capability === "locomotion" && request.motion
883
+ ? loaded.directionalStates?.[directionTo({ x: 0, y: 0 }, { x: request.motion.x, y: request.motion.y })]
884
+ : resolveCapabilityName(request.state, loaded);
885
+ const resolved = resolveState(requested, loaded.states);
886
+ let lift = 0;
887
+ if (request.motion) {
888
+ if (this.#locomotionState !== resolved.name) {
889
+ this.#locomotionState = resolved.name;
890
+ this.#locomotionElapsed = 0;
891
+ }
892
+ const state = loaded.states[resolved.name];
893
+ const cycle = animationCycleMs(state);
894
+ const previousCycles = this.#locomotionElapsed / cycle;
895
+ this.#locomotionElapsed += frame.stepMs;
896
+ const nextCycles = this.#locomotionElapsed / cycle;
897
+ const phaseDistance = nextCycles - previousCycles;
898
+ const authoredDistance = locomotionAdvance(loaded.locomotionMotion, previousCycles, nextCycles);
899
+ const factor = phaseDistance > 0 ? authoredDistance / phaseDistance : 1;
900
+ const requestedDistance = request.motion.speed * (frame.stepMs / 1000) * factor;
901
+ const distance = Math.min(requestedDistance, request.motion.maxDistance ?? requestedDistance);
902
+ this.#position = clampPosition({
903
+ x: this.#position.x + request.motion.x * distance,
904
+ y: this.#position.y + request.motion.y * distance,
905
+ }, world.viewport, characterSize.width, characterSize.height);
906
+ lift =
907
+ locomotionSample(loaded.locomotionMotion, nextCycles).lift *
908
+ characterSize.height;
909
+ }
910
+ else {
911
+ this.#locomotionState = "";
912
+ this.#locomotionElapsed = 0;
913
+ }
914
+ return { state: resolved.name, lift };
915
+ }
916
+ #tick(now, force = false) {
917
+ const frame = this.#beginFrame(now, force);
918
+ if (!frame || !this.#frameActive(frame))
919
+ return;
920
+ const snapshot = this.#snapshotFrame(frame);
921
+ if (!this.#frameActive(frame))
922
+ return;
923
+ const evaluation = this.#evaluateFrame(snapshot);
924
+ if (!this.#frameActive(frame))
925
+ return;
926
+ if (!this.#emitFrameDiagnostics(frame, evaluation.diagnostics))
927
+ return;
928
+ const presentation = this.#calculatePresentation(snapshot, evaluation);
929
+ if (!this.#frameActive(frame))
930
+ return;
931
+ this.#contentRenderer?.renderSurfaces(evaluation.request.surfaces ?? EMPTY_CONTENT_SELECTIONS, this.#content, this.#position, snapshot.world.viewport, snapshot.characterSize, this.#displayName);
932
+ if (!this.#frameActive(frame))
933
+ return;
934
+ frame.renderer.render(presentation.state, this.#animationClock, this.#position, snapshot.devicePixelRatio, presentation.lift);
935
+ if (!this.#frameActive(frame))
936
+ return;
937
+ this.#lastTick = frame.now;
938
+ this.#schedulePlan(frame);
939
+ if (this.#frameActive(frame) &&
940
+ (evaluation.request.motion || frame.input.reaction))
941
+ this.#wake();
942
+ }
943
+ #endPage() {
944
+ if (this.#destroyed)
945
+ return;
946
+ try {
947
+ if (this.#input && this.#plan) {
948
+ this.#drainPlanEvents(this.#now());
949
+ this.#input.captureReaction("page.lifecycle", { phase: "hide" }, "browser");
950
+ this.#drainPlanEvents(this.#now());
951
+ }
952
+ this.#notice("lifecycle.pagehide", "info", "lifecycle");
953
+ }
954
+ finally {
955
+ this.#destroy("pagehide");
956
+ }
957
+ }
958
+ #drainPlanEvents(now) {
959
+ if (!this.#input || !this.#plan)
960
+ return;
961
+ let reaction = this.#input.reaction;
962
+ while (reaction) {
963
+ this.#plan.evaluate({
964
+ now,
965
+ position: this.#position,
966
+ viewport: {
967
+ width: this.#window.innerWidth,
968
+ height: this.#window.innerHeight,
969
+ },
970
+ lastActivityAt: this.#input.lastActivityAt,
971
+ reducedMotion: this.#reducedMotion,
972
+ pageVisible: false,
973
+ reaction,
974
+ });
975
+ this.#seenReaction = reaction.id;
976
+ this.#input.consumeReaction(reaction.id);
977
+ reaction = this.#input.reaction;
978
+ }
979
+ }
980
+ }
981
+ /** Create one independently owned companion instance. */
982
+ export function hatch(input) {
983
+ return PeeklingRuntime.hatch(input);
984
+ }
985
+ function hasLocomotion(pack) {
986
+ return NATIVE_DIRECTIONS.every((direction) => Object.hasOwn(pack.directionalStates ?? {}, direction));
987
+ }
988
+ export function resolveInitialPosition(position, width, height, spriteWidth, spriteHeight) {
989
+ let point;
990
+ if (position === "center")
991
+ point = { x: width / 2, y: height / 2 };
992
+ else if (position === "bottom-right" || position === "bottom-left") {
993
+ const margin = 16;
994
+ point = {
995
+ x: position === "bottom-right"
996
+ ? width - spriteWidth / 2 - margin
997
+ : spriteWidth / 2 + margin,
998
+ y: height - spriteHeight / 2 - margin,
999
+ };
1000
+ }
1001
+ else if (typeof position === "object") {
1002
+ if (!Number.isFinite(position.x) || !Number.isFinite(position.y))
1003
+ throw new TypeError(runtimeMessage("position.coordinates", "position coordinates must be finite CSS pixels"));
1004
+ point = position;
1005
+ }
1006
+ else
1007
+ throw new TypeError(runtimeMessage("position.value", "position must be a preset or a finite {x,y} point"));
1008
+ return clampPosition(point, { width, height }, spriteWidth, spriteHeight);
1009
+ }
1010
+ function clampPosition(point, viewport, width, height) {
1011
+ return {
1012
+ x: Math.max(width / 2, Math.min(viewport.width - width / 2, point.x)),
1013
+ y: Math.max(height / 2, Math.min(viewport.height - height / 2, point.y)),
1014
+ };
1015
+ }