@omega.js/client 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 (72) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +874 -0
  3. package/dist/index.js +999 -0
  4. package/dist/modules/analytics.js +584 -0
  5. package/dist/modules/auth.js +469 -0
  6. package/dist/modules/bindings.js +319 -0
  7. package/dist/modules/device.js +282 -0
  8. package/dist/modules/dom.js +96 -0
  9. package/dist/modules/features.js +30 -0
  10. package/dist/modules/firestore.js +313 -0
  11. package/dist/modules/form-manager.js +1577 -0
  12. package/dist/modules/icon-core.js +226 -0
  13. package/dist/modules/icon-renderer.js +149 -0
  14. package/dist/modules/live-page.js +235 -0
  15. package/dist/modules/logger.js +36 -0
  16. package/dist/modules/motion.js +853 -0
  17. package/dist/modules/notifications.js +433 -0
  18. package/dist/modules/path-prefix.js +22 -0
  19. package/dist/modules/request.js +223 -0
  20. package/dist/modules/sentry.js +108 -0
  21. package/dist/modules/service-worker.js +237 -0
  22. package/dist/modules/storage.js +133 -0
  23. package/dist/modules/triggers.js +117 -0
  24. package/dist/modules/utilities.js +479 -0
  25. package/dist/modules/vert-document.js +354 -0
  26. package/dist/modules/verts.js +1133 -0
  27. package/dist/vendor/account/engine.js +182 -0
  28. package/dist/vendor/account/features.js +220 -0
  29. package/dist/vendor/account/index.js +53 -0
  30. package/dist/vendor/account/schema.js +272 -0
  31. package/dist/vendor/account/subscription.js +38 -0
  32. package/dist/vendor/analytics/adapters/ga4.js +26 -0
  33. package/dist/vendor/analytics/adapters/meta.js +26 -0
  34. package/dist/vendor/analytics/adapters/resolve.js +130 -0
  35. package/dist/vendor/analytics/adapters/tiktok.js +27 -0
  36. package/dist/vendor/analytics/catalog.js +908 -0
  37. package/dist/vendor/analytics/consent.js +49 -0
  38. package/dist/vendor/analytics/core.js +141 -0
  39. package/dist/vendor/analytics/identity.js +136 -0
  40. package/dist/vendor/analytics/index.js +170 -0
  41. package/dist/vendor/analytics/logger.js +40 -0
  42. package/dist/vendor/analytics/transports/browser.js +110 -0
  43. package/dist/vendor/monitoring/browser.js +207 -0
  44. package/dist/vendor/monitoring/core.js +180 -0
  45. package/dist/vendor/monitoring/logger.js +39 -0
  46. package/docs/architecture.md +59 -0
  47. package/docs/bindings.md +235 -0
  48. package/docs/build-system.md +32 -0
  49. package/docs/cdp-debugging.md +29 -0
  50. package/docs/code-patterns.md +96 -0
  51. package/docs/common-tasks.md +36 -0
  52. package/docs/dependencies.md +19 -0
  53. package/docs/index.md +159 -0
  54. package/docs/modules.md +180 -0
  55. package/docs/shared/agent-docs.md +89 -0
  56. package/docs/shared/analytics.md +612 -0
  57. package/docs/shared/brands.md +51 -0
  58. package/docs/shared/breaking-changes.md +497 -0
  59. package/docs/shared/config.md +1387 -0
  60. package/docs/shared/deploys.md +215 -0
  61. package/docs/shared/icons.md +201 -0
  62. package/docs/shared/local-dev.md +147 -0
  63. package/docs/shared/logging.md +202 -0
  64. package/docs/shared/monitoring.md +153 -0
  65. package/docs/shared/publishing.md +183 -0
  66. package/docs/shared/rulings.md +34 -0
  67. package/docs/shared/testing.md +147 -0
  68. package/docs/shared/theming.md +604 -0
  69. package/docs/shared/translation.md +291 -0
  70. package/docs/shared/updates.md +61 -0
  71. package/docs/testing.md +9 -0
  72. package/package.json +65 -0
@@ -0,0 +1,853 @@
1
+ /**
2
+ * motion — the ONE animation engine (C3 classy v2), shared by every browser
3
+ * surface the same way icon-renderer is: web pages today, desktop/extension
4
+ * at C4. It drives the `data-omega-*` motion attributes that the core motion
5
+ * stylesheet (web: core/css/motion/_index.scss) styles:
6
+ *
7
+ * [data-omega-reveal="up|fade|left|right|scale"] — reveal once on scroll-in
8
+ * [data-omega-reveal-stagger="60"] — parent staggers child reveals (ms step)
9
+ * [data-omega-countup] — number counts up from 0 on first view
10
+ * (markup carries the FINAL text: "12,400+")
11
+ * [data-omega-rotate="2600"] — children cycle via data-omega-active (ms)
12
+ * [data-omega-marquee] — the logo set is cloned until half the
13
+ * track covers the container, then that
14
+ * half is doubled so the -50% CSS loop is
15
+ * seamless and NEVER runs dry; speed is a
16
+ * constant px/s (attr value overrides)
17
+ * [data-omega-scroll-watch="24"] — stamps data-omega-scrolled="true|false"
18
+ * when window scrolls past the threshold
19
+ * [data-omega-segmented] — gliding-thumb segmented control: the
20
+ * engine injects .omega-segmented__thumb
21
+ * and keeps it under the checked/.active
22
+ * segment (CSS transitions do the glide)
23
+ * [data-omega-dotfield="22"] — canvas dot grid (value = px spacing):
24
+ * dots breathe on a slow traveling wave
25
+ * and tint along ONE rainbow gradient
26
+ * that drifts across the field; the
27
+ * pointer brightens/grows nearby dots
28
+ * (tracked window-level so fixed
29
+ * overlays like the nav can't blind it).
30
+ * The loop starts only once first paint
31
+ * has settled and repaints at 30fps
32
+ * (#752); see whenSettled below
33
+ *
34
+ * Resilience contract (mirrors the stylesheet):
35
+ * - The page stamps html[data-omega-motion] via an inline head script; the
36
+ * stylesheet only hides reveal targets under that stamp, so no-JS pages
37
+ * render fully visible.
38
+ * - prefers-reduced-motion: reveals resolve instantly, countups render their
39
+ * final value, rotators hold the first word, marquees stay static,
40
+ * dotfields paint ONE still grid and never loop, and any `video[autoplay]`
41
+ * parks paused with its controls on (CSS cannot pause a video, so this
42
+ * lane owns it for every autoplaying band).
43
+ * - start() is idempotent; a MutationObserver picks up inserted content, and
44
+ * scan(root) is exposed for callers that render into detached roots.
45
+ */
46
+
47
+ const REDUCED_QUERY = '(prefers-reduced-motion: reduce)';
48
+ const COUNTUP_DURATION = 1200;
49
+ const MARQUEE_SPEED = 80; // px/s — data-omega-marquee="120" overrides per marquee
50
+ const DOTFIELD_FRAME = 1000 / 30; // ms: the field repaints at 30fps, not 60 (#752)
51
+ const DOTFIELD_FRAME_SLOP = 4; // ms: rAF jitter must never drop a beat to 20fps
52
+ const SETTLE_TIMEOUT = 2000; // ms: a permanently busy page still gets its field
53
+
54
+ /**
55
+ * Whether the user asked for reduced motion.
56
+ * @returns {boolean}
57
+ */
58
+ function prefersReducedMotion() {
59
+ return typeof window !== 'undefined'
60
+ && typeof window.matchMedia === 'function'
61
+ && window.matchMedia(REDUCED_QUERY).matches;
62
+ }
63
+
64
+ /**
65
+ * Run `fn` once first paint has settled: the first idle callback after the
66
+ * load event, or the load event itself where requestIdleCallback is missing
67
+ * (Safari). Ambient canvas work is never worth a frame of the LCP window
68
+ * ([#752](https://github.com/Omega-JS-Stack/omega/issues/752)).
69
+ * @param {Document} doc - the target's own document
70
+ * @param {function} fn - the work that waits for the settle signal
71
+ */
72
+ function whenSettled(doc, fn) {
73
+ const idle = () => {
74
+ if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
75
+ window.requestIdleCallback(fn, { timeout: SETTLE_TIMEOUT });
76
+ return;
77
+ }
78
+ fn();
79
+ };
80
+
81
+ if (doc && doc.readyState !== 'complete') {
82
+ window.addEventListener('load', idle, { once: true });
83
+ return;
84
+ }
85
+
86
+ idle();
87
+ }
88
+
89
+ /**
90
+ * Parse a rendered number ("12,400+", "$1.2M", "99.98%") into its parts.
91
+ * Grouping is inherited from the markup: "2017" stays ungrouped (years!),
92
+ * "12,400" keeps its commas.
93
+ * @param {string} text - the element's final text
94
+ * @returns {{ prefix: string, value: number, decimals: number, suffix: string, grouped: boolean }|null}
95
+ */
96
+ function parseCountTarget(text) {
97
+ const match = String(text).trim().match(/^([^0-9-]*)(-?[\d,]*\.?\d+)(.*)$/s);
98
+ if (!match) {
99
+ return null;
100
+ }
101
+
102
+ const raw = match[2].replace(/,/g, '');
103
+ const value = Number(raw);
104
+ if (!Number.isFinite(value)) {
105
+ return null;
106
+ }
107
+
108
+ const decimals = raw.includes('.') ? raw.split('.')[1].length : 0;
109
+ return { prefix: match[1], value, decimals, suffix: match[3], grouped: match[2].includes(',') };
110
+ }
111
+
112
+ /**
113
+ * Format a count-up frame with the target's grouping and decimals.
114
+ * @param {{ prefix: string, decimals: number, suffix: string, grouped: boolean }} target
115
+ * @param {number} value - current frame value
116
+ * @returns {string}
117
+ */
118
+ function formatCount(target, value) {
119
+ const fixed = value.toFixed(target.decimals);
120
+ const [whole, fraction] = fixed.split('.');
121
+ const grouped = target.grouped ? whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : whole;
122
+ return `${target.prefix}${grouped}${fraction ? `.${fraction}` : ''}${target.suffix}`;
123
+ }
124
+
125
+ /**
126
+ * Normalize any CSS color (hex, rgb, named, token value) to [r, g, b] using
127
+ * the canvas context's own parser. Returns null for unparseable input.
128
+ * @param {CanvasRenderingContext2D} ctx
129
+ * @param {string} value
130
+ * @returns {number[]|null}
131
+ */
132
+ function parseColor(ctx, value) {
133
+ const raw = String(value || '').trim();
134
+ if (!raw) {
135
+ return null;
136
+ }
137
+
138
+ ctx.fillStyle = '#000000';
139
+ ctx.fillStyle = raw;
140
+ const parsed = String(ctx.fillStyle);
141
+
142
+ if (parsed.startsWith('#')) {
143
+ return [
144
+ parseInt(parsed.slice(1, 3), 16),
145
+ parseInt(parsed.slice(3, 5), 16),
146
+ parseInt(parsed.slice(5, 7), 16),
147
+ ];
148
+ }
149
+
150
+ const match = parsed.match(/rgba?\(([^)]+)\)/);
151
+ if (!match) {
152
+ return null;
153
+ }
154
+ const parts = match[1].split(',').map((part) => parseFloat(part));
155
+ return [parts[0], parts[1], parts[2]];
156
+ }
157
+
158
+ // Dotfield rainbow ramp: the classy card ring's hand-mixed PASTEL stops, in
159
+ // order, so the dots sweep the exact colors of the gradient ring (Ian
160
+ // 2026-07-31 — a generic HSL rainbow reads wrong). Mirrored from
161
+ // `@omega.js/web` themes/classy/css/base/_utilities.scss
162
+ // (--classy-gradient-stops); packages/web/test/rainbow-pin.test.js holds the
163
+ // two lists equal. Last stop repeats the first so the sweep wraps seamlessly.
164
+ const RAINBOW_STOPS = ['#f2d478', '#f2a288', '#f094c2', '#bd97f2', '#88b4f2', '#7fd9cf', '#a5e08c', '#f2d478'];
165
+ const RAINBOW_RGB = RAINBOW_STOPS.map((hex) => [
166
+ parseInt(hex.slice(1, 3), 16),
167
+ parseInt(hex.slice(3, 5), 16),
168
+ parseInt(hex.slice(5, 7), 16),
169
+ ]);
170
+ const RAINBOW_SEGMENTS = RAINBOW_RGB.length - 1;
171
+
172
+ /**
173
+ * Rainbow color for a dotfield dot: the palette position advances along the
174
+ * grid diagonal (one full sweep ≈ 1.25 container widths) and drifts with time
175
+ * (a full cycle every ~20s), so every dot samples one moving gradient. The
176
+ * position lands inside a stop pair and blends the two per channel.
177
+ * @param {number} x - dot x within the field (px)
178
+ * @param {number} y - dot y within the field (px)
179
+ * @param {number} width - field width (px)
180
+ * @param {number} t - elapsed seconds
181
+ * @param {number[]} [out] - reusable output array (hot-loop friendly)
182
+ * @returns {number[]} [r, g, b] channels 0-255
183
+ */
184
+ function rainbowColor(x, y, width, t, out = []) {
185
+ const pos = (((((x + (y * 0.35)) / (Math.max(width, 1) * 1.25)) + (t * 0.05)) % 1) + 1) % 1;
186
+ const scaled = pos * RAINBOW_SEGMENTS;
187
+ const index = Math.min(Math.floor(scaled), RAINBOW_SEGMENTS - 1);
188
+ const blend = scaled - index;
189
+ const from = RAINBOW_RGB[index];
190
+ const to = RAINBOW_RGB[index + 1];
191
+ out[0] = Math.round(from[0] + ((to[0] - from[0]) * blend));
192
+ out[1] = Math.round(from[1] + ((to[1] - from[1]) * blend));
193
+ out[2] = Math.round(from[2] + ((to[2] - from[2]) * blend));
194
+ return out;
195
+ }
196
+
197
+ /**
198
+ * How many copies of the item set each track HALF needs so the -50% loop
199
+ * never shows a gap: the half must be at least as wide as the container.
200
+ * Unmeasurable geometry (jsdom, display:none) falls back to one copy.
201
+ * @param {number} setWidth - width of one full set of items
202
+ * @param {number} containerWidth - visible marquee width
203
+ * @returns {number}
204
+ */
205
+ function marqueeCopies(setWidth, containerWidth) {
206
+ if (!(setWidth > 0) || !(containerWidth > 0)) {
207
+ return 1;
208
+ }
209
+ return Math.max(1, Math.ceil(containerWidth / setWidth));
210
+ }
211
+
212
+ /**
213
+ * Create the motion engine. One instance per document is plenty.
214
+ * @returns {{ start: function, stop: function, scan: function }}
215
+ */
216
+ function createMotion() {
217
+ let started = false;
218
+ let revealObserver = null;
219
+ let countObserver = null;
220
+ let mutationObserver = null;
221
+ let scrollWatchers = [];
222
+ let rotateTimers = new Map();
223
+ let dotfields = new WeakSet();
224
+ let scrollHandler = null;
225
+
226
+ // ── reveals ────────────────────────────────────────────────────────────────
227
+
228
+ const markInview = (el) => el.setAttribute('data-omega-inview', 'true');
229
+
230
+ const observeReveal = (el) => {
231
+ if (el.hasAttribute('data-omega-inview')) {
232
+ return;
233
+ }
234
+ if (!revealObserver) {
235
+ markInview(el);
236
+ return;
237
+ }
238
+ revealObserver.observe(el);
239
+ };
240
+
241
+ const applyStagger = (parent) => {
242
+ const step = Number(parent.getAttribute('data-omega-reveal-stagger')) || 60;
243
+ parent.querySelectorAll('[data-omega-reveal]').forEach((el, index) => {
244
+ el.style.setProperty('--omega-reveal-delay', `${index * step}ms`);
245
+ });
246
+ };
247
+
248
+ // ── count-up ───────────────────────────────────────────────────────────────
249
+
250
+ const runCountup = (el) => {
251
+ if (el.dataset.omegaCountupDone) {
252
+ return;
253
+ }
254
+ el.dataset.omegaCountupDone = 'true';
255
+
256
+ const target = parseCountTarget(el.textContent);
257
+ if (!target || prefersReducedMotion()) {
258
+ return; // markup already shows the final value
259
+ }
260
+
261
+ const startTime = performance.now();
262
+ const tick = (now) => {
263
+ const progress = Math.min((now - startTime) / COUNTUP_DURATION, 1);
264
+ const eased = 1 - ((1 - progress) ** 3);
265
+ el.textContent = formatCount(target, target.value * eased);
266
+ if (progress < 1) {
267
+ requestAnimationFrame(tick);
268
+ }
269
+ };
270
+ el.textContent = formatCount(target, 0);
271
+ requestAnimationFrame(tick);
272
+ };
273
+
274
+ const observeCountup = (el) => {
275
+ if (el.dataset.omegaCountupDone) {
276
+ return;
277
+ }
278
+ if (!countObserver) {
279
+ runCountup(el);
280
+ return;
281
+ }
282
+ countObserver.observe(el);
283
+ };
284
+
285
+ // ── word rotator ───────────────────────────────────────────────────────────
286
+
287
+ const setupRotate = (el) => {
288
+ if (rotateTimers.has(el)) {
289
+ return;
290
+ }
291
+
292
+ const words = Array.from(el.children);
293
+ if (!words.length) {
294
+ return;
295
+ }
296
+
297
+ let index = 0;
298
+ words.forEach((word, i) => word.toggleAttribute('data-omega-active', i === 0));
299
+
300
+ if (prefersReducedMotion() || words.length < 2) {
301
+ rotateTimers.set(el, null); // static first word, but marked as handled
302
+ return;
303
+ }
304
+
305
+ const interval = Number(el.getAttribute('data-omega-rotate')) || 2600;
306
+ const timer = setInterval(() => {
307
+ if (!el.isConnected) {
308
+ clearInterval(timer);
309
+ rotateTimers.delete(el);
310
+ return;
311
+ }
312
+ index = (index + 1) % words.length;
313
+ words.forEach((word, i) => word.toggleAttribute('data-omega-active', i === index));
314
+ }, interval);
315
+ rotateTimers.set(el, timer);
316
+ };
317
+
318
+ // ── marquee ────────────────────────────────────────────────────────────────
319
+
320
+ const setupMarquee = (el) => {
321
+ if (el.dataset.omegaMarqueeReady) {
322
+ return;
323
+ }
324
+ el.dataset.omegaMarqueeReady = 'true';
325
+
326
+ if (prefersReducedMotion()) {
327
+ return; // CSS animation is off — leave the single static row
328
+ }
329
+
330
+ const track = el.querySelector('.omega-marquee__track');
331
+ if (!track || !track.children.length) {
332
+ return;
333
+ }
334
+
335
+ const setItems = Array.from(track.children);
336
+ const speed = Number(el.getAttribute('data-omega-marquee')) || MARQUEE_SPEED;
337
+
338
+ // (Re)build: reset to the original set, clone it until half the track
339
+ // covers the container, then double that half so translateX(-50%) loops
340
+ // seamlessly. Duration scales with the half width → constant px/s. The
341
+ // measure waits for the NEXT frame: reading geometry in the tick that just
342
+ // replaced the children forces a synchronous reflow (#752).
343
+ const build = () => {
344
+ track.replaceChildren(...setItems);
345
+
346
+ requestAnimationFrame(() => {
347
+ if (track.children.length !== setItems.length) {
348
+ return; // a burst (resize, fonts, images) queued two: one rebuild wins
349
+ }
350
+
351
+ const setWidth = track.getBoundingClientRect().width;
352
+ const copies = marqueeCopies(setWidth, el.clientWidth);
353
+ const total = copies * 2 * setItems.length;
354
+
355
+ while (track.children.length < total) {
356
+ const clone = setItems[track.children.length % setItems.length].cloneNode(true);
357
+ clone.setAttribute('aria-hidden', 'true');
358
+ // Clones stay mouse-clickable (they occupy most of the viewport as
359
+ // the track scrolls) but must not duplicate the tab order — so
360
+ // focusables get tabindex=-1, NOT inert.
361
+ const FOCUSABLE = 'a, button, input, select, textarea, [tabindex]';
362
+ if (clone.matches(FOCUSABLE)) {
363
+ clone.setAttribute('tabindex', '-1');
364
+ }
365
+ clone.querySelectorAll(FOCUSABLE).forEach(($focusable) => {
366
+ $focusable.setAttribute('tabindex', '-1');
367
+ });
368
+ track.appendChild(clone);
369
+ }
370
+
371
+ if (setWidth > 0) {
372
+ track.style.setProperty('--omega-marquee-speed', `${Math.round((setWidth * copies) / speed)}s`);
373
+ }
374
+ });
375
+ };
376
+
377
+ build();
378
+
379
+ // Re-run when geometry settles or changes: container resize, webfont
380
+ // swap, and images inside the set finishing their load.
381
+ if (typeof ResizeObserver === 'function') {
382
+ let lastWidth = el.clientWidth;
383
+ new ResizeObserver(() => {
384
+ if (el.clientWidth !== lastWidth) {
385
+ lastWidth = el.clientWidth;
386
+ build();
387
+ }
388
+ }).observe(el);
389
+ }
390
+ if (typeof document !== 'undefined') {
391
+ document.fonts?.ready?.then(() => build()).catch(() => {});
392
+ }
393
+ setItems.forEach((item) => {
394
+ item.querySelectorAll?.('img').forEach((img) => {
395
+ if (!img.complete) {
396
+ img.addEventListener('load', () => build(), { once: true });
397
+ }
398
+ });
399
+ });
400
+ };
401
+
402
+ // ── segmented controls ─────────────────────────────────────────────────────
403
+
404
+ const setupSegmented = (el) => {
405
+ if (el.dataset.omegaSegmentedReady) {
406
+ return;
407
+ }
408
+
409
+ const doc = el.ownerDocument;
410
+ const thumb = doc.createElement('span');
411
+ thumb.className = 'omega-segmented__thumb';
412
+ thumb.setAttribute('aria-hidden', 'true');
413
+ el.prepend(thumb);
414
+ el.dataset.omegaSegmentedReady = 'true';
415
+
416
+ const position = () => {
417
+ const active = el.querySelector('.btn-check:checked + *')
418
+ || el.querySelector('.active, [aria-pressed="true"]');
419
+ if (!active || !active.offsetWidth) {
420
+ thumb.style.opacity = '0';
421
+ return;
422
+ }
423
+ thumb.style.opacity = '';
424
+ thumb.style.setProperty('--omega-segment-x', `${active.offsetLeft}px`);
425
+ thumb.style.setProperty('--omega-segment-y', `${active.offsetTop}px`);
426
+ thumb.style.setProperty('--omega-segment-w', `${active.offsetWidth}px`);
427
+ thumb.style.setProperty('--omega-segment-h', `${active.offsetHeight}px`);
428
+ };
429
+
430
+ // First position lands without a glide-in from 0,0
431
+ thumb.style.transition = 'none';
432
+ position();
433
+ requestAnimationFrame(() => {
434
+ thumb.style.transition = '';
435
+ position();
436
+ });
437
+
438
+ // User input, programmatic .active/aria-pressed stamps (tab JS, platform
439
+ // detection, appearance boot), geometry changes — all re-position.
440
+ const deferred = () => requestAnimationFrame(position);
441
+ el.addEventListener('click', deferred);
442
+ el.addEventListener('change', deferred);
443
+ if (typeof MutationObserver === 'function') {
444
+ new MutationObserver(position).observe(el, {
445
+ attributes: true,
446
+ subtree: true,
447
+ attributeFilter: ['class', 'aria-pressed', 'aria-selected'],
448
+ });
449
+ }
450
+ if (typeof ResizeObserver === 'function') {
451
+ new ResizeObserver(position).observe(el);
452
+ }
453
+ doc.fonts?.ready?.then(position).catch(() => {});
454
+ };
455
+
456
+ // ── dot field ──────────────────────────────────────────────────────────────
457
+
458
+ const setupDotfield = (el) => {
459
+ // Installed-here (this engine) or stamped-there (a previous one): the
460
+ // handoff stamp now waits for the first painted grid, so it cannot double
461
+ // as the re-entry guard between the scan and the settle signal.
462
+ if (dotfields.has(el) || el.dataset.omegaDotfieldReady) {
463
+ return;
464
+ }
465
+
466
+ const reduced = prefersReducedMotion();
467
+ const doc = el.ownerDocument;
468
+ const canvas = doc.createElement('canvas');
469
+ canvas.className = 'omega-dotfield__canvas';
470
+ canvas.setAttribute('aria-hidden', 'true');
471
+ const ctx = canvas.getContext?.('2d');
472
+ if (!ctx) {
473
+ return; // no canvas support — the static CSS dot grid stays
474
+ }
475
+
476
+ dotfields.add(el);
477
+ el.prepend(canvas);
478
+
479
+ const spacing = Number(el.getAttribute('data-omega-dotfield')) || 22;
480
+ const colors = { base: [128, 128, 128] };
481
+ const pointer = { x: -1e4, y: -1e4, targetX: -1e4, targetY: -1e4 };
482
+ let width = 0;
483
+ let height = 0;
484
+ let dpr = 1;
485
+ let running = false;
486
+ let settled = false;
487
+ let raf = 0;
488
+ let painted = false;
489
+ let lastColorRead = 0;
490
+ let lastPaint = -Infinity;
491
+
492
+ const readColors = () => {
493
+ lastColorRead = performance.now();
494
+ const styles = window.getComputedStyle(el);
495
+ colors.base = parseColor(ctx, styles.getPropertyValue('--omega-line-strong')) || colors.base;
496
+ };
497
+
498
+ const resize = () => {
499
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
500
+ width = el.clientWidth;
501
+ height = el.clientHeight;
502
+ canvas.width = Math.round(width * dpr);
503
+ canvas.height = Math.round(height * dpr);
504
+ };
505
+
506
+ const WAVE_LENGTH = (Math.PI * 2) / 900; // ~900px crest-to-crest diagonal
507
+ const POINTER_RADIUS = 2 * 110 * 110; // gaussian falloff (~110px reach)
508
+
509
+ // One grid, at time `now` (seconds of wave travel). The whole cost of the
510
+ // field lives here (every dot is an arc fill), so the loop below rations
511
+ // it rather than running it on every frame the display offers.
512
+ const paint = (now) => {
513
+ // Theme flips (data-bs-theme) land within a second
514
+ if (now - lastColorRead > 1000) {
515
+ readColors();
516
+ }
517
+
518
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
519
+ ctx.clearRect(0, 0, width, height);
520
+
521
+ const [br, bg, bb] = colors.base;
522
+ const t = now / 1000;
523
+ const rgb = [0, 0, 0]; // reused per dot — no per-frame allocation churn
524
+
525
+ for (let y = spacing / 2; y < height; y += spacing) {
526
+ for (let x = spacing / 2; x < width; x += spacing) {
527
+ // Slow diagonal wave sweeping the grid; pointer adds a local lift
528
+ const wave = 0.5 + 0.5 * Math.sin((x + y * 0.6) * WAVE_LENGTH - t * 0.7);
529
+ const dx = x - pointer.x;
530
+ const dy = y - pointer.y;
531
+ const boost = Math.exp(-(dx * dx + dy * dy) / POINTER_RADIUS);
532
+
533
+ rainbowColor(x, y, width, t, rgb);
534
+ const mix = Math.min(1, wave * 0.45 + boost * 0.55);
535
+ const alpha = Math.min(1, 0.28 + wave * 0.38 + boost * 0.5);
536
+ const radius = 1 + wave * 0.35 + boost * 0.9;
537
+
538
+ ctx.fillStyle = `rgba(${Math.round(br + (rgb[0] - br) * mix)},${Math.round(bg + (rgb[1] - bg) * mix)},${Math.round(bb + (rgb[2] - bb) * mix)},${alpha})`;
539
+ ctx.beginPath();
540
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
541
+ ctx.fill();
542
+ }
543
+ }
544
+
545
+ if (!painted) {
546
+ painted = true;
547
+ // The CSS fallback dots (.omega-dotgrid::before) fade out on this
548
+ // stamp, so it lands with the grid that replaces them and never
549
+ // before: at scan it left the hero backdrop blank until settle (#752).
550
+ el.dataset.omegaDotfieldReady = 'true';
551
+ }
552
+ };
553
+
554
+ const draw = (now) => {
555
+ raf = 0;
556
+ if (!running) {
557
+ return;
558
+ }
559
+ raf = requestAnimationFrame(draw);
560
+
561
+ // Ease the pointer toward its target for a soft trail. Every frame, so
562
+ // the trail keeps its feel while the grid repaints at half the rate.
563
+ pointer.x += (pointer.targetX - pointer.x) * 0.12;
564
+ pointer.y += (pointer.targetY - pointer.y) * 0.12;
565
+
566
+ // The wave crawls (~900px crest, a 20s rainbow cycle), so 30fps reads
567
+ // exactly like 60 and costs half the main thread. The slop absorbs rAF
568
+ // jitter: without it a 33.3ms budget missed by a hair halves to 20fps.
569
+ if (now - lastPaint < DOTFIELD_FRAME - DOTFIELD_FRAME_SLOP) {
570
+ return;
571
+ }
572
+ lastPaint = now;
573
+ paint(now);
574
+ };
575
+
576
+ const startLoop = () => {
577
+ if (settled && running && !raf) {
578
+ raf = requestAnimationFrame(draw);
579
+ }
580
+ };
581
+
582
+ const setRunning = (on) => {
583
+ if (on === running) {
584
+ return;
585
+ }
586
+ running = on;
587
+ startLoop();
588
+ };
589
+
590
+ // Nothing measures, reads styles or paints until first paint has settled:
591
+ // an ambient field is never worth a frame of the LCP window (#752).
592
+ whenSettled(doc, () => {
593
+ settled = true;
594
+ readColors();
595
+ resize();
596
+
597
+ if (reduced) {
598
+ paint(0); // the wave held at its start: one grid, and no loop
599
+ return;
600
+ }
601
+
602
+ startLoop();
603
+ });
604
+
605
+ if (reduced) {
606
+ // A visitor who asked for stillness gets the field, still: no pointer
607
+ // tracking, no visibility loop. It repaints only when what it draws
608
+ // changes, and re-reads its color there: the animated path's 1s color
609
+ // poll lives in the loop this path does not have.
610
+ const restill = (measure) => {
611
+ if (!settled) {
612
+ return;
613
+ }
614
+ if (measure) {
615
+ resize();
616
+ }
617
+ readColors();
618
+ paint(0);
619
+ };
620
+
621
+ if (typeof ResizeObserver === 'function') {
622
+ new ResizeObserver(() => restill(true)).observe(el);
623
+ }
624
+ if (typeof MutationObserver === 'function') {
625
+ // Bound to documentElement, which outlives the field: let go the
626
+ // moment the field leaves the DOM, or a detached canvas repaints on
627
+ // every theme flip for the life of the page.
628
+ const themeObserver = new MutationObserver(() => {
629
+ if (el.isConnected === false) {
630
+ themeObserver.disconnect();
631
+ return;
632
+ }
633
+ restill(false);
634
+ });
635
+ themeObserver.observe(doc.documentElement, {
636
+ attributes: true,
637
+ attributeFilter: ['data-bs-theme'],
638
+ });
639
+ }
640
+ return;
641
+ }
642
+
643
+ // Window-level tracking: an el-level pointermove stops firing while the
644
+ // cursor rides a covering element (the fixed nav over a hero), so the
645
+ // boost froze there and JUMPED on re-entry. The window always fires;
646
+ // out-of-range coordinates park the boost off-field smoothly.
647
+ const POINTER_MARGIN = 140;
648
+ window.addEventListener('pointermove', (event) => {
649
+ if (!running) {
650
+ return; // offscreen fields skip the rect read
651
+ }
652
+ const rect = el.getBoundingClientRect();
653
+ const x = event.clientX - rect.left;
654
+ const y = event.clientY - rect.top;
655
+ if (x < -POINTER_MARGIN || y < -POINTER_MARGIN
656
+ || x > rect.width + POINTER_MARGIN || y > rect.height + POINTER_MARGIN) {
657
+ pointer.targetX = -1e4;
658
+ pointer.targetY = -1e4;
659
+ return;
660
+ }
661
+ pointer.targetX = x;
662
+ pointer.targetY = y;
663
+ }, { passive: true });
664
+ doc.documentElement.addEventListener('mouseleave', () => {
665
+ pointer.targetX = -1e4;
666
+ pointer.targetY = -1e4;
667
+ });
668
+
669
+ if (typeof ResizeObserver === 'function') {
670
+ new ResizeObserver(() => {
671
+ if (settled) {
672
+ resize(); // before settle the canvas is sized by whenSettled, not here
673
+ }
674
+ }).observe(el);
675
+ }
676
+
677
+ // Only draw while on screen and the tab is visible
678
+ if (typeof IntersectionObserver === 'function') {
679
+ new IntersectionObserver((entries) => {
680
+ entries.forEach((entry) => setRunning(entry.isIntersecting && !doc.hidden));
681
+ }).observe(el);
682
+ } else {
683
+ setRunning(true);
684
+ }
685
+ doc.addEventListener('visibilitychange', () => {
686
+ if (doc.hidden) {
687
+ setRunning(false);
688
+ } else if (el.isConnected) {
689
+ setRunning(true);
690
+ }
691
+ });
692
+ };
693
+
694
+ // ── autoplaying video ──────────────────────────────────────────────────────
695
+
696
+ const setupVideo = (el) => {
697
+ if (el.dataset.omegaVideoReady) {
698
+ return;
699
+ }
700
+ el.dataset.omegaVideoReady = 'true';
701
+
702
+ if (!prefersReducedMotion()) {
703
+ return; // the authored playback stands
704
+ }
705
+
706
+ // CSS can park an animation; it cannot pause a <video>. So the engine
707
+ // does: the poster (or first frame) holds, the controls come on, and the
708
+ // visitor decides whether anything moves. One lane for every autoplaying
709
+ // band — no section forks its own.
710
+ el.removeAttribute('autoplay');
711
+ el.autoplay = false;
712
+ el.controls = true;
713
+ el.pause?.();
714
+ };
715
+
716
+ // ── scroll watch ───────────────────────────────────────────────────────────
717
+
718
+ const syncScrollWatchers = () => {
719
+ const y = window.scrollY;
720
+ scrollWatchers = scrollWatchers.filter((entry) => entry.el.isConnected);
721
+ scrollWatchers.forEach((entry) => {
722
+ entry.el.setAttribute('data-omega-scrolled', y > entry.threshold ? 'true' : 'false');
723
+ });
724
+ };
725
+
726
+ const setupScrollWatch = (el) => {
727
+ if (scrollWatchers.some((entry) => entry.el === el)) {
728
+ return;
729
+ }
730
+ scrollWatchers.push({ el, threshold: Number(el.getAttribute('data-omega-scroll-watch')) || 24 });
731
+ syncScrollWatchers();
732
+ };
733
+
734
+ // ── scan ───────────────────────────────────────────────────────────────────
735
+
736
+ /**
737
+ * Wire every motion attribute under (and on) a root. Idempotent.
738
+ * @param {Element|Document} root
739
+ */
740
+ const scan = (root) => {
741
+ if (!root || !root.querySelectorAll) {
742
+ return;
743
+ }
744
+
745
+ const all = (selector) => {
746
+ const found = Array.from(root.querySelectorAll(selector));
747
+ if (root.matches?.(selector)) {
748
+ found.unshift(root);
749
+ }
750
+ return found;
751
+ };
752
+
753
+ all('[data-omega-reveal-stagger]').forEach(applyStagger);
754
+ all('[data-omega-reveal]').forEach(observeReveal);
755
+ all('[data-omega-countup]').forEach(observeCountup);
756
+ all('[data-omega-rotate]').forEach(setupRotate);
757
+ all('[data-omega-marquee]').forEach(setupMarquee);
758
+ all('[data-omega-scroll-watch]').forEach(setupScrollWatch);
759
+ all('video[autoplay]').forEach(setupVideo);
760
+ all('[data-omega-segmented]').forEach(setupSegmented);
761
+ all('[data-omega-dotfield]').forEach(setupDotfield);
762
+ };
763
+
764
+ /**
765
+ * Scan the document and observe it for inserted motion targets. Idempotent.
766
+ * @param {Document} [doc] - Defaults to the global document.
767
+ */
768
+ const start = (doc = typeof document !== 'undefined' ? document : null) => {
769
+ if (!doc || started) {
770
+ return;
771
+ }
772
+ started = true;
773
+
774
+ const begin = () => {
775
+ if (typeof IntersectionObserver === 'function' && !prefersReducedMotion()) {
776
+ revealObserver = new IntersectionObserver((entries) => {
777
+ entries.forEach((entry) => {
778
+ if (entry.isIntersecting) {
779
+ markInview(entry.target);
780
+ revealObserver.unobserve(entry.target);
781
+ }
782
+ });
783
+ }, { threshold: 0.1, rootMargin: '0px 0px -8% 0px' });
784
+
785
+ countObserver = new IntersectionObserver((entries) => {
786
+ entries.forEach((entry) => {
787
+ if (entry.isIntersecting) {
788
+ runCountup(entry.target);
789
+ countObserver.unobserve(entry.target);
790
+ }
791
+ });
792
+ }, { threshold: 0.4 });
793
+ }
794
+
795
+ scrollHandler = (() => {
796
+ let ticking = false;
797
+ return () => {
798
+ if (ticking) {
799
+ return;
800
+ }
801
+ ticking = true;
802
+ requestAnimationFrame(() => {
803
+ syncScrollWatchers();
804
+ ticking = false;
805
+ });
806
+ };
807
+ })();
808
+ window.addEventListener('scroll', scrollHandler, { passive: true });
809
+
810
+ if (typeof MutationObserver === 'function') {
811
+ mutationObserver = new MutationObserver((mutations) => {
812
+ for (const mutation of mutations) {
813
+ mutation.addedNodes.forEach((node) => {
814
+ if (node.nodeType === 1) {
815
+ scan(node);
816
+ }
817
+ });
818
+ }
819
+ });
820
+ mutationObserver.observe(doc.documentElement, { childList: true, subtree: true });
821
+ }
822
+
823
+ scan(doc);
824
+ };
825
+
826
+ if (doc.documentElement && doc.readyState !== 'loading') {
827
+ begin();
828
+ } else {
829
+ doc.addEventListener('DOMContentLoaded', begin, { once: true });
830
+ }
831
+ };
832
+
833
+ const stop = () => {
834
+ revealObserver?.disconnect();
835
+ countObserver?.disconnect();
836
+ mutationObserver?.disconnect();
837
+ revealObserver = null;
838
+ countObserver = null;
839
+ mutationObserver = null;
840
+ if (scrollHandler) {
841
+ window.removeEventListener('scroll', scrollHandler);
842
+ scrollHandler = null;
843
+ }
844
+ rotateTimers.forEach((timer) => timer && clearInterval(timer));
845
+ rotateTimers = new Map();
846
+ scrollWatchers = [];
847
+ started = false;
848
+ };
849
+
850
+ return { start, stop, scan };
851
+ }
852
+
853
+ module.exports = { createMotion, parseCountTarget, formatCount, marqueeCopies, parseColor, rainbowColor };