@holdyapp/sdk 1.5.0 → 1.6.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.
package/dist/index.mjs CHANGED
@@ -600,6 +600,119 @@ function installFrictionCapture(opts) {
600
600
  };
601
601
  }
602
602
 
603
+ // src/skeleton.ts
604
+ var SKELETON_SELECTOR = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [data-testid]';
605
+ var SKELETON_MAX_ELEMENTS = 80;
606
+ var SKELETON_DELAY_MS = 1500;
607
+ var SENT_STORAGE_KEY = "holdy_skeletons";
608
+ var SENT_STORAGE_MAX = 200;
609
+ function fnv1a(input) {
610
+ let hash = 2166136261;
611
+ for (let i = 0; i < input.length; i++) {
612
+ hash ^= input.charCodeAt(i);
613
+ hash = Math.imul(hash, 16777619) >>> 0;
614
+ }
615
+ return hash.toString(16).padStart(8, "0");
616
+ }
617
+ function attr(el, name, max) {
618
+ const value = el.getAttribute(name);
619
+ if (!value) return void 0;
620
+ const trimmed = value.replace(/\s+/g, " ").trim().slice(0, max);
621
+ return trimmed || void 0;
622
+ }
623
+ function isVisible(el) {
624
+ if (!el.getBoundingClientRect) return true;
625
+ const rect = el.getBoundingClientRect();
626
+ return rect.width > 0 && rect.height > 0;
627
+ }
628
+ function relativeBox(rect, viewport) {
629
+ const pct = (value, base) => base > 0 ? Math.max(0, Math.min(100, Math.round(value / base * 100))) : 0;
630
+ return [pct(rect.x, viewport.width), pct(rect.y + viewport.scrollY, viewport.height), pct(rect.width, viewport.width), pct(rect.height, viewport.height)];
631
+ }
632
+ function describeSkeletonElement(el, viewport) {
633
+ const out = { t: el.tagName.toLowerCase() };
634
+ const role = attr(el, "role", 40);
635
+ if (role) out.r = role;
636
+ const testid = attr(el, "data-testid", 80);
637
+ if (testid) out.id = testid;
638
+ const name = attr(el, "name", 80);
639
+ if (name) out.n = name;
640
+ const label = attr(el, "aria-label", 80);
641
+ if (label) out.l = label;
642
+ if (out.t === "input" || out.t === "button") {
643
+ const type = attr(el, "type", 20);
644
+ if (type) out.k = type.toLowerCase();
645
+ }
646
+ if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport);
647
+ return out;
648
+ }
649
+ function collectSkeleton(root, path, viewport) {
650
+ const found = root.querySelectorAll(SKELETON_SELECTOR);
651
+ const visible = [];
652
+ for (let i = 0; i < found.length; i++) {
653
+ const el = found[i];
654
+ if (el.tagName.toLowerCase() === "input" && (el.getAttribute("type") ?? "").toLowerCase() === "hidden") continue;
655
+ if (!isVisible(el)) continue;
656
+ visible.push(el);
657
+ }
658
+ const elements = visible.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
659
+ const structural = elements.map(({ b: _b, ...rest }) => rest);
660
+ return { path, hash: fnv1a(JSON.stringify(structural)), count: visible.length, elements };
661
+ }
662
+ function loadSent() {
663
+ try {
664
+ const raw = sessionStorage.getItem(SENT_STORAGE_KEY);
665
+ const list = raw ? JSON.parse(raw) : [];
666
+ return new Set(Array.isArray(list) ? list.filter((x) => typeof x === "string") : []);
667
+ } catch {
668
+ return /* @__PURE__ */ new Set();
669
+ }
670
+ }
671
+ function saveSent(sent) {
672
+ try {
673
+ sessionStorage.setItem(SENT_STORAGE_KEY, JSON.stringify([...sent].slice(-SENT_STORAGE_MAX)));
674
+ } catch {
675
+ return;
676
+ }
677
+ }
678
+ function installSkeletonCapture(opts) {
679
+ if (typeof window === "undefined" || typeof document === "undefined") {
680
+ return { onNavigate: () => void 0, teardown: () => void 0 };
681
+ }
682
+ const delay = opts.delayMs ?? SKELETON_DELAY_MS;
683
+ const sent = loadSent();
684
+ let pending = null;
685
+ const capture = (path) => {
686
+ pending = null;
687
+ if (location.pathname !== path) return;
688
+ try {
689
+ const viewport = {
690
+ width: window.innerWidth,
691
+ height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
692
+ scrollY: window.scrollY
693
+ };
694
+ const skeleton = collectSkeleton(document, path, viewport);
695
+ const key = `${path}:${skeleton.hash}`;
696
+ if (sent.has(key)) return;
697
+ sent.add(key);
698
+ saveSent(sent);
699
+ opts.onSkeleton(skeleton);
700
+ } catch {
701
+ return;
702
+ }
703
+ };
704
+ return {
705
+ onNavigate: (path) => {
706
+ if (pending) clearTimeout(pending);
707
+ pending = setTimeout(() => capture(path), delay);
708
+ },
709
+ teardown: () => {
710
+ if (pending) clearTimeout(pending);
711
+ pending = null;
712
+ }
713
+ };
714
+ }
715
+
603
716
  // src/web-context.ts
604
717
  var UTM_KEYS = [
605
718
  "utm_source",
@@ -718,7 +831,7 @@ function buildWebContext() {
718
831
  }
719
832
 
720
833
  // src/arm.ts
721
- function fnv1a(input) {
834
+ function fnv1a2(input) {
722
835
  let hash = 2166136261;
723
836
  for (let i = 0; i < input.length; i++) {
724
837
  hash ^= input.charCodeAt(i);
@@ -727,7 +840,7 @@ function fnv1a(input) {
727
840
  return hash >>> 0;
728
841
  }
729
842
  function changeBucket(changeId, accountKey) {
730
- return fnv1a(`${changeId}:${accountKey}`) % 100;
843
+ return fnv1a2(`${changeId}:${accountKey}`) % 100;
731
844
  }
732
845
  function changeArm(changeId, accountKey, holdoutPct) {
733
846
  return changeBucket(changeId, accountKey) < holdoutPct ? "holdout" : "change";
@@ -1017,6 +1130,7 @@ var HoldyWeb = class extends HoldyCore {
1017
1130
  teardownAutoCapture = null;
1018
1131
  friction = null;
1019
1132
  changes = null;
1133
+ skeleton = null;
1020
1134
  constructor(opts) {
1021
1135
  super("sdk_web", opts);
1022
1136
  this.anonymousId = resolveAnonymousId();
@@ -1034,12 +1148,19 @@ var HoldyWeb = class extends HoldyCore {
1034
1148
  onFriction: (signal) => this.track(`$${signal.kind}`, signal.properties)
1035
1149
  });
1036
1150
  }
1151
+ if (opts.skeleton !== false) {
1152
+ this.skeleton = installSkeletonCapture({
1153
+ onSkeleton: (skeleton) => this.track("$page_skeleton", { ...skeleton })
1154
+ });
1155
+ }
1037
1156
  if (opts.autoCapture !== false) {
1038
1157
  this.teardownAutoCapture = installAutoCapture({
1039
1158
  onNavigate: (kind) => {
1040
1159
  this.page(void 0, { navigation: kind });
1041
- this.friction?.onNavigate(typeof location !== "undefined" ? location.pathname : "/");
1160
+ const path = typeof location !== "undefined" ? location.pathname : "/";
1161
+ this.friction?.onNavigate(path);
1042
1162
  this.changes?.onNavigate();
1163
+ this.skeleton?.onNavigate(path);
1043
1164
  }
1044
1165
  });
1045
1166
  }
@@ -1089,6 +1210,8 @@ var HoldyWeb = class extends HoldyCore {
1089
1210
  this.friction = null;
1090
1211
  this.changes?.teardown();
1091
1212
  this.changes = null;
1213
+ this.skeleton?.teardown();
1214
+ this.skeleton = null;
1092
1215
  await super.shutdown();
1093
1216
  }
1094
1217
  environmentContext() {