@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.cjs CHANGED
@@ -630,6 +630,119 @@ function installFrictionCapture(opts) {
630
630
  };
631
631
  }
632
632
 
633
+ // src/skeleton.ts
634
+ 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]';
635
+ var SKELETON_MAX_ELEMENTS = 80;
636
+ var SKELETON_DELAY_MS = 1500;
637
+ var SENT_STORAGE_KEY = "holdy_skeletons";
638
+ var SENT_STORAGE_MAX = 200;
639
+ function fnv1a(input) {
640
+ let hash = 2166136261;
641
+ for (let i = 0; i < input.length; i++) {
642
+ hash ^= input.charCodeAt(i);
643
+ hash = Math.imul(hash, 16777619) >>> 0;
644
+ }
645
+ return hash.toString(16).padStart(8, "0");
646
+ }
647
+ function attr(el, name, max) {
648
+ const value = el.getAttribute(name);
649
+ if (!value) return void 0;
650
+ const trimmed = value.replace(/\s+/g, " ").trim().slice(0, max);
651
+ return trimmed || void 0;
652
+ }
653
+ function isVisible(el) {
654
+ if (!el.getBoundingClientRect) return true;
655
+ const rect = el.getBoundingClientRect();
656
+ return rect.width > 0 && rect.height > 0;
657
+ }
658
+ function relativeBox(rect, viewport) {
659
+ const pct = (value, base) => base > 0 ? Math.max(0, Math.min(100, Math.round(value / base * 100))) : 0;
660
+ return [pct(rect.x, viewport.width), pct(rect.y + viewport.scrollY, viewport.height), pct(rect.width, viewport.width), pct(rect.height, viewport.height)];
661
+ }
662
+ function describeSkeletonElement(el, viewport) {
663
+ const out = { t: el.tagName.toLowerCase() };
664
+ const role = attr(el, "role", 40);
665
+ if (role) out.r = role;
666
+ const testid = attr(el, "data-testid", 80);
667
+ if (testid) out.id = testid;
668
+ const name = attr(el, "name", 80);
669
+ if (name) out.n = name;
670
+ const label = attr(el, "aria-label", 80);
671
+ if (label) out.l = label;
672
+ if (out.t === "input" || out.t === "button") {
673
+ const type = attr(el, "type", 20);
674
+ if (type) out.k = type.toLowerCase();
675
+ }
676
+ if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport);
677
+ return out;
678
+ }
679
+ function collectSkeleton(root, path, viewport) {
680
+ const found = root.querySelectorAll(SKELETON_SELECTOR);
681
+ const visible = [];
682
+ for (let i = 0; i < found.length; i++) {
683
+ const el = found[i];
684
+ if (el.tagName.toLowerCase() === "input" && (el.getAttribute("type") ?? "").toLowerCase() === "hidden") continue;
685
+ if (!isVisible(el)) continue;
686
+ visible.push(el);
687
+ }
688
+ const elements = visible.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
689
+ const structural = elements.map(({ b: _b, ...rest }) => rest);
690
+ return { path, hash: fnv1a(JSON.stringify(structural)), count: visible.length, elements };
691
+ }
692
+ function loadSent() {
693
+ try {
694
+ const raw = sessionStorage.getItem(SENT_STORAGE_KEY);
695
+ const list = raw ? JSON.parse(raw) : [];
696
+ return new Set(Array.isArray(list) ? list.filter((x) => typeof x === "string") : []);
697
+ } catch {
698
+ return /* @__PURE__ */ new Set();
699
+ }
700
+ }
701
+ function saveSent(sent) {
702
+ try {
703
+ sessionStorage.setItem(SENT_STORAGE_KEY, JSON.stringify([...sent].slice(-SENT_STORAGE_MAX)));
704
+ } catch {
705
+ return;
706
+ }
707
+ }
708
+ function installSkeletonCapture(opts) {
709
+ if (typeof window === "undefined" || typeof document === "undefined") {
710
+ return { onNavigate: () => void 0, teardown: () => void 0 };
711
+ }
712
+ const delay = opts.delayMs ?? SKELETON_DELAY_MS;
713
+ const sent = loadSent();
714
+ let pending = null;
715
+ const capture = (path) => {
716
+ pending = null;
717
+ if (location.pathname !== path) return;
718
+ try {
719
+ const viewport = {
720
+ width: window.innerWidth,
721
+ height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
722
+ scrollY: window.scrollY
723
+ };
724
+ const skeleton = collectSkeleton(document, path, viewport);
725
+ const key = `${path}:${skeleton.hash}`;
726
+ if (sent.has(key)) return;
727
+ sent.add(key);
728
+ saveSent(sent);
729
+ opts.onSkeleton(skeleton);
730
+ } catch {
731
+ return;
732
+ }
733
+ };
734
+ return {
735
+ onNavigate: (path) => {
736
+ if (pending) clearTimeout(pending);
737
+ pending = setTimeout(() => capture(path), delay);
738
+ },
739
+ teardown: () => {
740
+ if (pending) clearTimeout(pending);
741
+ pending = null;
742
+ }
743
+ };
744
+ }
745
+
633
746
  // src/web-context.ts
634
747
  var UTM_KEYS = [
635
748
  "utm_source",
@@ -748,7 +861,7 @@ function buildWebContext() {
748
861
  }
749
862
 
750
863
  // src/arm.ts
751
- function fnv1a(input) {
864
+ function fnv1a2(input) {
752
865
  let hash = 2166136261;
753
866
  for (let i = 0; i < input.length; i++) {
754
867
  hash ^= input.charCodeAt(i);
@@ -757,7 +870,7 @@ function fnv1a(input) {
757
870
  return hash >>> 0;
758
871
  }
759
872
  function changeBucket(changeId, accountKey) {
760
- return fnv1a(`${changeId}:${accountKey}`) % 100;
873
+ return fnv1a2(`${changeId}:${accountKey}`) % 100;
761
874
  }
762
875
  function changeArm(changeId, accountKey, holdoutPct) {
763
876
  return changeBucket(changeId, accountKey) < holdoutPct ? "holdout" : "change";
@@ -1047,6 +1160,7 @@ var HoldyWeb = class extends HoldyCore {
1047
1160
  teardownAutoCapture = null;
1048
1161
  friction = null;
1049
1162
  changes = null;
1163
+ skeleton = null;
1050
1164
  constructor(opts) {
1051
1165
  super("sdk_web", opts);
1052
1166
  this.anonymousId = resolveAnonymousId();
@@ -1064,12 +1178,19 @@ var HoldyWeb = class extends HoldyCore {
1064
1178
  onFriction: (signal) => this.track(`$${signal.kind}`, signal.properties)
1065
1179
  });
1066
1180
  }
1181
+ if (opts.skeleton !== false) {
1182
+ this.skeleton = installSkeletonCapture({
1183
+ onSkeleton: (skeleton) => this.track("$page_skeleton", { ...skeleton })
1184
+ });
1185
+ }
1067
1186
  if (opts.autoCapture !== false) {
1068
1187
  this.teardownAutoCapture = installAutoCapture({
1069
1188
  onNavigate: (kind) => {
1070
1189
  this.page(void 0, { navigation: kind });
1071
- this.friction?.onNavigate(typeof location !== "undefined" ? location.pathname : "/");
1190
+ const path = typeof location !== "undefined" ? location.pathname : "/";
1191
+ this.friction?.onNavigate(path);
1072
1192
  this.changes?.onNavigate();
1193
+ this.skeleton?.onNavigate(path);
1073
1194
  }
1074
1195
  });
1075
1196
  }
@@ -1119,6 +1240,8 @@ var HoldyWeb = class extends HoldyCore {
1119
1240
  this.friction = null;
1120
1241
  this.changes?.teardown();
1121
1242
  this.changes = null;
1243
+ this.skeleton?.teardown();
1244
+ this.skeleton = null;
1122
1245
  await super.shutdown();
1123
1246
  }
1124
1247
  environmentContext() {