@holdyapp/sdk 1.5.0 → 1.9.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 (49) hide show
  1. package/README.md +9 -1
  2. package/dist/changes.d.ts +16 -2
  3. package/dist/changes.d.ts.map +1 -1
  4. package/dist/core.d.ts +1 -0
  5. package/dist/core.d.ts.map +1 -1
  6. package/dist/friction.d.ts +2 -1
  7. package/dist/friction.d.ts.map +1 -1
  8. package/dist/holdy.js +25 -4
  9. package/dist/holdy.js.map +4 -4
  10. package/dist/index.cjs +540 -33
  11. package/dist/index.cjs.map +4 -4
  12. package/dist/index.mjs +540 -33
  13. package/dist/index.mjs.map +4 -4
  14. package/dist/server.cjs +3 -0
  15. package/dist/server.cjs.map +2 -2
  16. package/dist/server.mjs +3 -0
  17. package/dist/server.mjs.map +2 -2
  18. package/dist/skeleton.d.ts +52 -0
  19. package/dist/skeleton.d.ts.map +1 -0
  20. package/dist/theme.d.ts +43 -0
  21. package/dist/theme.d.ts.map +1 -0
  22. package/dist/tip-css.d.ts +9 -0
  23. package/dist/tip-css.d.ts.map +1 -0
  24. package/dist/types.d.ts +6 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/web-context.d.ts +1 -0
  27. package/dist/web-context.d.ts.map +1 -1
  28. package/dist/web.d.ts +10 -1
  29. package/dist/web.d.ts.map +1 -1
  30. package/dist/web.mjs +540 -33
  31. package/dist/web.mjs.map +4 -4
  32. package/package.json +1 -1
  33. package/src/changes.dom.test.ts +221 -0
  34. package/src/changes.test.ts +38 -0
  35. package/src/changes.ts +299 -26
  36. package/src/core.test.ts +15 -0
  37. package/src/core.ts +3 -0
  38. package/src/friction.dom.test.ts +54 -0
  39. package/src/friction.test.ts +43 -0
  40. package/src/friction.ts +44 -7
  41. package/src/skeleton.test.ts +60 -0
  42. package/src/skeleton.ts +182 -0
  43. package/src/theme.test.ts +118 -0
  44. package/src/theme.ts +120 -0
  45. package/src/tip-css.ts +32 -0
  46. package/src/types.ts +6 -0
  47. package/src/web-context.test.ts +18 -0
  48. package/src/web-context.ts +8 -0
  49. package/src/web.ts +28 -3
package/dist/index.mjs CHANGED
@@ -11,6 +11,7 @@ var HoldyCore = class {
11
11
  flushAt;
12
12
  debug;
13
13
  source;
14
+ release;
14
15
  queue = [];
15
16
  timer = null;
16
17
  flushing = null;
@@ -27,6 +28,7 @@ var HoldyCore = class {
27
28
  this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
28
29
  this.flushAt = Math.max(1, opts.flushAt ?? DEFAULT_FLUSH_AT);
29
30
  this.debug = opts.debug ?? false;
31
+ this.release = typeof opts.release === "string" && opts.release.trim() ? opts.release.trim().slice(0, 64) : null;
30
32
  const intervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
31
33
  if (intervalMs > 0) {
32
34
  this.timer = setInterval(() => void this.flush(), intervalMs);
@@ -43,6 +45,7 @@ var HoldyCore = class {
43
45
  const occurredAt = overrides?.occurredAt ? new Date(overrides.occurredAt).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
44
46
  const context = {
45
47
  ...this.environmentContext(),
48
+ ...this.release ? { release: this.release } : {},
46
49
  ...overrides?.context ?? {}
47
50
  };
48
51
  if (accountKey) context.account_external_id = accountKey;
@@ -344,11 +347,14 @@ var NavLoopDetector = class {
344
347
  return null;
345
348
  }
346
349
  };
350
+ function tagOf(el) {
351
+ return typeof el?.tagName === "string" ? el.tagName.toLowerCase() : "";
352
+ }
347
353
  function resolveInteractive(el) {
348
354
  if (!el) return null;
349
355
  const viaClosest = el.closest?.(INTERACTIVE_SELECTOR) ?? null;
350
356
  if (viaClosest) return viaClosest;
351
- const tag = el.tagName.toLowerCase();
357
+ const tag = tagOf(el);
352
358
  if (INTERACTIVE_TAGS.has(tag)) return el;
353
359
  const role = el.getAttribute("role");
354
360
  if (role === "button" || role === "link" || role === "menuitem" || role === "tab") return el;
@@ -360,7 +366,7 @@ function isDeadClickTarget(el) {
360
366
  if (target.disabled === true) return true;
361
367
  if (target.getAttribute("disabled") !== null) return true;
362
368
  if (target.getAttribute("aria-disabled") === "true") return true;
363
- const tag = target.tagName.toLowerCase();
369
+ const tag = tagOf(target);
364
370
  if (tag === "a") {
365
371
  const href = target.getAttribute("href");
366
372
  if (!href || href === "#" || href.startsWith("javascript:")) return true;
@@ -370,7 +376,7 @@ function isDeadClickTarget(el) {
370
376
  function describeElement(el) {
371
377
  const target = resolveInteractive(el) ?? el;
372
378
  if (!target) return {};
373
- const out = { element: target.tagName.toLowerCase() };
379
+ const out = { element: tagOf(target) };
374
380
  if (target.id) out.element_id = target.id;
375
381
  const role = target.getAttribute("role");
376
382
  if (role) out.element_role = role;
@@ -398,6 +404,22 @@ function errorMessage(reason) {
398
404
  return String(reason);
399
405
  }
400
406
  }
407
+ var STACK_MAX_FRAMES = 8;
408
+ var STACK_MAX_FRAME_CHARS = 200;
409
+ var STACK_FRAME = /^at\s|^\S*@\S+:\d+(?::\d+)?$/;
410
+ function sanitizeStack(stack) {
411
+ if (typeof stack !== "string" || !stack) return null;
412
+ const frames = stack.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && STACK_FRAME.test(line)).slice(0, STACK_MAX_FRAMES).map(
413
+ (line) => line.replace(/(https?:\/\/[^\s)?#]+)[?#][^\s)]*?(?=(?::\d+){0,2}\)?$)/, "$1").slice(0, STACK_MAX_FRAME_CHARS)
414
+ );
415
+ return frames.length > 0 ? frames.join("\n") : null;
416
+ }
417
+ function withStack(stack) {
418
+ return stack ? { stack } : {};
419
+ }
420
+ function stackOf(reason) {
421
+ return typeof reason === "object" && reason !== null ? sanitizeStack(reason.stack) : null;
422
+ }
401
423
  function isAbortError(err) {
402
424
  return typeof err === "object" && err !== null && err.name === "AbortError";
403
425
  }
@@ -475,13 +497,19 @@ function installFrictionCapture(opts) {
475
497
  emit("js_error", {
476
498
  message: String(ev.message ?? "").slice(0, 200),
477
499
  ...ev.filename ? { source: stripRequestUrl(ev.filename, location.href) } : {},
478
- ...ev.lineno ? { line: ev.lineno } : {}
500
+ ...ev.lineno ? { line: ev.lineno } : {},
501
+ ...ev.colno ? { column: ev.colno } : {},
502
+ ...withStack(stackOf(ev.error))
479
503
  });
480
504
  };
481
505
  window.addEventListener("error", onError);
482
506
  cleanups.push(() => window.removeEventListener("error", onError));
483
507
  const onRejection = (ev) => {
484
- emit("js_error", { message: errorMessage(ev.reason).slice(0, 200), unhandled_rejection: true });
508
+ emit("js_error", {
509
+ message: errorMessage(ev.reason).slice(0, 200),
510
+ unhandled_rejection: true,
511
+ ...withStack(stackOf(ev.reason))
512
+ });
485
513
  };
486
514
  window.addEventListener("unhandledrejection", onRejection);
487
515
  cleanups.push(() => window.removeEventListener("unhandledrejection", onRejection));
@@ -563,11 +591,11 @@ function installFrictionCapture(opts) {
563
591
  }
564
592
  const onFocusIn = (ev) => {
565
593
  const el = ev.target;
566
- if (!el || !FORM_CONTROL_TAGS.has(el.tagName.toLowerCase())) return;
594
+ if (!el || !FORM_CONTROL_TAGS.has(tagOf(el))) return;
567
595
  const form = el.form;
568
596
  if (!form) return;
569
597
  const entry = touched.get(form) ?? { form: formKey(form), fields: /* @__PURE__ */ new Set() };
570
- const field = el.name || el.id || el.tagName.toLowerCase();
598
+ const field = el.name || el.id || tagOf(el);
571
599
  entry.fields.add(field);
572
600
  touched.set(form, entry);
573
601
  };
@@ -600,6 +628,200 @@ function installFrictionCapture(opts) {
600
628
  };
601
629
  }
602
630
 
631
+ // src/theme.ts
632
+ var THEME_BUTTON_SELECTOR = 'button[type="submit"],[data-testid*="primary"],.btn-primary,button,[role="button"],a.button';
633
+ var THEME_MAX_BUTTONS = 40;
634
+ var THEME_KEYS = ["font", "size", "fg", "bg", "accent", "accentFg", "radius"];
635
+ var COLOR = /^(rgba?\([\d.,\s%/]{1,40}\)|#[0-9a-fA-F]{3,8}|(?:color|oklch|oklab|hsla?|lab|lch)\([^()]{1,60}\))$/;
636
+ var MIN_ACCENT_CONTRAST = 60;
637
+ var RGB = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/;
638
+ function colorDistance(a, b) {
639
+ const x = RGB.exec(a);
640
+ const y = b ? RGB.exec(b) : null;
641
+ if (!x) return MIN_ACCENT_CONTRAST + 1;
642
+ if (!y) return MIN_ACCENT_CONTRAST + 1;
643
+ return Math.abs(+x[1] - +y[1]) + Math.abs(+x[2] - +y[2]) + Math.abs(+x[3] - +y[3]);
644
+ }
645
+ var TRANSPARENT = /^(transparent|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)|rgba?\(\s*\d+\s+\d+\s+\d+\s*\/\s*0\s*\))$/;
646
+ function themeVarName(key) {
647
+ return `--holdy-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
648
+ }
649
+ function cleanColor(value) {
650
+ const v = (value ?? "").trim();
651
+ return v && v.length <= 80 && !TRANSPARENT.test(v) && COLOR.test(v) ? v : void 0;
652
+ }
653
+ function cleanFont(value) {
654
+ const first = (value ?? "").split(",")[0].trim().replace(/^["']|["']$/g, "").trim();
655
+ return /^[\w\s-]{1,60}$/.test(first) ? first : void 0;
656
+ }
657
+ function cleanPx(value, min, max) {
658
+ const m = /^(\d+(?:\.\d+)?)px/.exec((value ?? "").trim());
659
+ const n = m ? Math.round(Number(m[1])) : NaN;
660
+ return n >= min && n <= max ? `${n}px` : void 0;
661
+ }
662
+ function visible(el) {
663
+ const r = el.getBoundingClientRect?.();
664
+ return !r || r.width > 0 && r.height > 0;
665
+ }
666
+ function readTheme(doc, getStyle, anchor = null) {
667
+ const body = doc.body;
668
+ if (!body) return null;
669
+ const bodyStyle = getStyle(body);
670
+ const theme = {};
671
+ const put = (key, value) => {
672
+ if (value) theme[key] = value;
673
+ };
674
+ put("font", cleanFont(bodyStyle.getPropertyValue("font-family")));
675
+ put("size", cleanPx(bodyStyle.getPropertyValue("font-size"), 10, 20));
676
+ put("fg", cleanColor(bodyStyle.getPropertyValue("color")));
677
+ put("bg", cleanColor(bodyStyle.getPropertyValue("background-color")) ?? cleanColor(getStyle(doc.documentElement).getPropertyValue("background-color")));
678
+ const candidates = doc.querySelectorAll(THEME_BUTTON_SELECTOR);
679
+ let button = null;
680
+ let best = MIN_ACCENT_CONTRAST;
681
+ for (let i = 0; i < candidates.length && i < THEME_MAX_BUTTONS; i++) {
682
+ const el = candidates[i];
683
+ if (!visible(el)) continue;
684
+ const s = getStyle(el);
685
+ const accent = cleanColor(s.getPropertyValue("background-color"));
686
+ if (!accent || accent === theme.bg) continue;
687
+ const contrast = colorDistance(accent, theme.bg);
688
+ if (contrast <= best) continue;
689
+ best = contrast;
690
+ put("accent", accent);
691
+ put("accentFg", cleanColor(s.getPropertyValue("color")));
692
+ button = el;
693
+ }
694
+ const radiusSource = anchor && anchor.tagName.toLowerCase() === "button" ? anchor : button;
695
+ if (radiusSource) put("radius", cleanPx(getStyle(radiusSource).getPropertyValue("border-top-left-radius"), 0, 24));
696
+ return Object.keys(theme).length > 0 ? theme : null;
697
+ }
698
+ function themeVars(theme) {
699
+ const out = [];
700
+ for (const key of THEME_KEYS) {
701
+ const value = theme?.[key];
702
+ if (value) out.push([themeVarName(key), value]);
703
+ }
704
+ return out;
705
+ }
706
+ function applyThemeVars(el, theme) {
707
+ for (const key of THEME_KEYS) el.style.removeProperty(themeVarName(key));
708
+ for (const [name, value] of themeVars(theme)) el.style.setProperty(name, value);
709
+ }
710
+
711
+ // src/skeleton.ts
712
+ 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]';
713
+ var SKELETON_MAX_ELEMENTS = 80;
714
+ var SKELETON_DELAY_MS = 1500;
715
+ var SENT_STORAGE_KEY = "holdy_skeletons";
716
+ var SENT_STORAGE_MAX = 200;
717
+ function fnv1a(input) {
718
+ let hash = 2166136261;
719
+ for (let i = 0; i < input.length; i++) {
720
+ hash ^= input.charCodeAt(i);
721
+ hash = Math.imul(hash, 16777619) >>> 0;
722
+ }
723
+ return hash.toString(16).padStart(8, "0");
724
+ }
725
+ function attr(el, name, max) {
726
+ const value = el.getAttribute(name);
727
+ if (!value) return void 0;
728
+ const trimmed = value.replace(/\s+/g, " ").trim().slice(0, max);
729
+ return trimmed || void 0;
730
+ }
731
+ function isVisible(el) {
732
+ if (!el.getBoundingClientRect) return true;
733
+ const rect = el.getBoundingClientRect();
734
+ return rect.width > 0 && rect.height > 0;
735
+ }
736
+ function relativeBox(rect, viewport) {
737
+ const pct = (value, base) => base > 0 ? Math.max(0, Math.min(100, Math.round(value / base * 100))) : 0;
738
+ return [pct(rect.x, viewport.width), pct(rect.y + viewport.scrollY, viewport.height), pct(rect.width, viewport.width), pct(rect.height, viewport.height)];
739
+ }
740
+ function describeSkeletonElement(el, viewport) {
741
+ const out = { t: el.tagName.toLowerCase() };
742
+ const role = attr(el, "role", 40);
743
+ if (role) out.r = role;
744
+ const testid = attr(el, "data-testid", 80);
745
+ if (testid) out.id = testid;
746
+ const name = attr(el, "name", 80);
747
+ if (name) out.n = name;
748
+ const label = attr(el, "aria-label", 80);
749
+ if (label) out.l = label;
750
+ if (out.t === "input" || out.t === "button") {
751
+ const type = attr(el, "type", 20);
752
+ if (type) out.k = type.toLowerCase();
753
+ }
754
+ if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport);
755
+ return out;
756
+ }
757
+ function collectSkeleton(root, path, viewport, theme = null) {
758
+ const found = root.querySelectorAll(SKELETON_SELECTOR);
759
+ const visible2 = [];
760
+ for (let i = 0; i < found.length; i++) {
761
+ const el = found[i];
762
+ if (el.tagName.toLowerCase() === "input" && (el.getAttribute("type") ?? "").toLowerCase() === "hidden") continue;
763
+ if (!isVisible(el)) continue;
764
+ visible2.push(el);
765
+ }
766
+ const elements = visible2.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
767
+ const structural = elements.map(({ b: _b, ...rest }) => rest);
768
+ return { path, hash: fnv1a(JSON.stringify(structural)), count: visible2.length, elements, ...theme ? { theme } : {} };
769
+ }
770
+ function loadSent() {
771
+ try {
772
+ const raw = sessionStorage.getItem(SENT_STORAGE_KEY);
773
+ const list = raw ? JSON.parse(raw) : [];
774
+ return new Set(Array.isArray(list) ? list.filter((x) => typeof x === "string") : []);
775
+ } catch {
776
+ return /* @__PURE__ */ new Set();
777
+ }
778
+ }
779
+ function saveSent(sent) {
780
+ try {
781
+ sessionStorage.setItem(SENT_STORAGE_KEY, JSON.stringify([...sent].slice(-SENT_STORAGE_MAX)));
782
+ } catch {
783
+ return;
784
+ }
785
+ }
786
+ function installSkeletonCapture(opts) {
787
+ if (typeof window === "undefined" || typeof document === "undefined") {
788
+ return { onNavigate: () => void 0, teardown: () => void 0 };
789
+ }
790
+ const delay = opts.delayMs ?? SKELETON_DELAY_MS;
791
+ const sent = loadSent();
792
+ let pending = null;
793
+ const capture = (path) => {
794
+ pending = null;
795
+ if (location.pathname !== path) return;
796
+ try {
797
+ const viewport = {
798
+ width: window.innerWidth,
799
+ height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
800
+ scrollY: window.scrollY
801
+ };
802
+ const theme = readTheme(document, (el) => window.getComputedStyle(el));
803
+ const skeleton = collectSkeleton(document, path, viewport, theme);
804
+ const key = `${path}:${skeleton.hash}`;
805
+ if (sent.has(key)) return;
806
+ sent.add(key);
807
+ saveSent(sent);
808
+ opts.onSkeleton(skeleton);
809
+ } catch {
810
+ return;
811
+ }
812
+ };
813
+ return {
814
+ onNavigate: (path) => {
815
+ if (pending) clearTimeout(pending);
816
+ pending = setTimeout(() => capture(path), delay);
817
+ },
818
+ teardown: () => {
819
+ if (pending) clearTimeout(pending);
820
+ pending = null;
821
+ }
822
+ };
823
+ }
824
+
603
825
  // src/web-context.ts
604
826
  var UTM_KEYS = [
605
827
  "utm_source",
@@ -664,6 +886,12 @@ function randomId() {
664
886
  return `hol-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
665
887
  }
666
888
  }
889
+ function clearSessionId() {
890
+ try {
891
+ sessionStorage.removeItem(SESSION_STORAGE_KEY);
892
+ } catch {
893
+ }
894
+ }
667
895
  function resolveSessionId() {
668
896
  const existing = readStorage("session", SESSION_STORAGE_KEY);
669
897
  if (existing) return existing;
@@ -717,8 +945,35 @@ function buildWebContext() {
717
945
  };
718
946
  }
719
947
 
948
+ // src/tip-css.ts
949
+ var TIP_CSS = `:host{all:initial;--h-bg:#111;--h-fg:#fff;--h-accent:#fff;--h-accent-fg:#111;--h-border:transparent}
950
+ @media (prefers-color-scheme:dark){:host{--h-bg:#fff;--h-fg:#111;--h-accent:#111;--h-accent-fg:#fff}}
951
+ .tip{position:fixed;box-sizing:border-box;max-width:280px;padding:10px 12px;border-radius:var(--holdy-radius,8px);background:var(--holdy-bg,var(--h-bg));color:var(--holdy-fg,var(--h-fg));border:1px solid color-mix(in srgb,var(--holdy-fg,var(--h-fg)) 14%,transparent);font:var(--holdy-size,13px)/1.45 var(--holdy-font,system-ui),system-ui,-apple-system,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.18);pointer-events:auto;display:flex;gap:10px;align-items:flex-start}
952
+ .tip p{margin:0;flex:1}
953
+ .tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1;color:inherit}
954
+ .tip button:hover,.tip button:focus-visible{opacity:1}
955
+ .ask{flex-direction:column;width:300px;max-width:calc(100vw - 16px);gap:8px}
956
+ .ask .head{display:flex;gap:10px;align-items:flex-start;width:100%}
957
+ .ask textarea{all:unset;box-sizing:border-box;width:100%;min-height:56px;padding:8px;border-radius:calc(var(--holdy-radius,8px) * .75);background:color-mix(in srgb,var(--holdy-fg,var(--h-fg)) 8%,transparent);color:inherit;font:inherit;white-space:pre-wrap;overflow-wrap:anywhere;cursor:text}
958
+ .ask textarea:focus-visible{outline:2px solid color-mix(in srgb,var(--holdy-fg,var(--h-fg)) 45%,transparent)}
959
+ .ask .send{align-self:flex-end;padding:5px 12px;border-radius:calc(var(--holdy-radius,8px) * .75);background:var(--holdy-accent,var(--h-accent));color:var(--holdy-accent-fg,var(--h-accent-fg));font-size:12.5px;font-weight:500;opacity:1}
960
+ .ask .send[disabled]{opacity:.4;cursor:default}
961
+ .list{flex-direction:column;width:300px;max-width:calc(100vw - 16px);gap:8px}
962
+ .list .head{display:flex;gap:10px;align-items:flex-start;width:100%}
963
+ .list .head p{display:flex;justify-content:space-between;gap:10px;align-items:baseline}
964
+ .list .count{opacity:.7;font-size:12px;white-space:nowrap}
965
+ .list ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px;width:100%}
966
+ .list li{display:flex;gap:8px;align-items:flex-start}
967
+ .list li.done{opacity:.55;text-decoration:line-through}
968
+ .list .mark{flex:none;width:16px;height:16px;border-radius:4px;border:1px solid color-mix(in srgb,var(--holdy-fg,var(--h-fg)) 45%,transparent);font-size:11px;line-height:16px;text-align:center}
969
+ .list li.done .mark{background:var(--holdy-accent,var(--h-accent));color:var(--holdy-accent-fg,var(--h-accent-fg));border-color:var(--holdy-accent,var(--h-accent))}
970
+ .list.folded ul{display:none}
971
+ .list.folded{width:auto}
972
+ .preview{flex:none;order:-1;width:100%;font-size:11px;opacity:.7}
973
+ .tip:has(.preview){flex-wrap:wrap;row-gap:4px}`;
974
+
720
975
  // src/arm.ts
721
- function fnv1a(input) {
976
+ function fnv1a2(input) {
722
977
  let hash = 2166136261;
723
978
  for (let i = 0; i < input.length; i++) {
724
979
  hash ^= input.charCodeAt(i);
@@ -727,7 +982,7 @@ function fnv1a(input) {
727
982
  return hash >>> 0;
728
983
  }
729
984
  function changeBucket(changeId, accountKey) {
730
- return fnv1a(`${changeId}:${accountKey}`) % 100;
985
+ return fnv1a2(`${changeId}:${accountKey}`) % 100;
731
986
  }
732
987
  function changeArm(changeId, accountKey, holdoutPct) {
733
988
  return changeBucket(changeId, accountKey) < holdoutPct ? "holdout" : "change";
@@ -746,12 +1001,16 @@ var SCAN_DEBOUNCE_MS = 250;
746
1001
  var SEEN_KEY = "holdy_changes_seen";
747
1002
  var DISMISSED_KEY = "holdy_changes_dismissed";
748
1003
  var TIP_GAP_PX = 8;
749
- var TIP_CSS = `:host{all:initial}
750
- .tip{position:fixed;box-sizing:border-box;max-width:280px;padding:10px 12px;border-radius:8px;background:#111;color:#fff;font:13px/1.45 system-ui,-apple-system,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.18);pointer-events:auto;display:flex;gap:10px;align-items:flex-start}
751
- .tip p{margin:0;flex:1}
752
- .tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1}
753
- .tip button:hover,.tip button:focus-visible{opacity:1}
754
- @media (prefers-color-scheme:dark){.tip{background:#fff;color:#111}}`;
1004
+ var ANSWERED_KEY = "holdy_questions_answered";
1005
+ var FOLDED_KEY = "holdy_checklist_folded";
1006
+ var COMPLETED_KEY = "holdy_checklist_completed";
1007
+ var PROGRESS_REFRESH_MS = 60 * 1e3;
1008
+ var VOICE_EVENT = "$voice";
1009
+ var MAX_ANSWER_CHARS = 1e3;
1010
+ var THANKS_MS = 1600;
1011
+ var PREVIEW_PARAM = "holdy_preview";
1012
+ var PREVIEW_KEY = "holdy_preview";
1013
+ var PREVIEW_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
755
1014
  function normalizeText(text) {
756
1015
  return text.replace(/\s+/g, " ").trim();
757
1016
  }
@@ -764,13 +1023,42 @@ function isAnchor(value) {
764
1023
  if (a?.kind === "page") return true;
765
1024
  return (a?.kind === "selector" || a?.kind === "text") && typeof a.value === "string" && a.value.length > 0;
766
1025
  }
1026
+ function isItem(value) {
1027
+ const i = value;
1028
+ return typeof i?.definition_name === "string" && typeof i.label === "string" && Array.isArray(i.events) && i.events.every((e) => typeof e === "string");
1029
+ }
767
1030
  function isChange(value) {
768
1031
  const c = value;
769
- return typeof c?.id === "string" && (c.kind === "copy" || c.kind === "guidance") && typeof c.path === "string" && isAnchor(c.anchor) && typeof c.content?.text === "string" && typeof c.holdout_pct === "number";
1032
+ return typeof c?.id === "string" && (c.kind === "copy" || c.kind === "guidance" || c.kind === "question" && typeof c.plan_id === "string" || c.kind === "checklist" && Array.isArray(c.content?.items) && c.content.items.length > 0 && c.content.items.every(isItem)) && typeof c.path === "string" && isAnchor(c.anchor) && typeof c.content?.text === "string" && typeof c.holdout_pct === "number";
770
1033
  }
771
- function changesForPage(changes, path, accountKey) {
772
- if (!accountKey) return [];
773
- return changes.filter((c) => matchesPathTemplate(c.path, path)).map((change) => ({ change, arm: changeArm(change.id, accountKey, change.holdout_pct) }));
1034
+ function changesForPage(changes, path, accountKey, previewId = null) {
1035
+ return changes.filter((c) => c.kind === "checklist" || matchesPathTemplate(c.path, path)).flatMap((change) => {
1036
+ if (change.id === previewId) return [{ change, arm: "change", preview: true }];
1037
+ if (!accountKey) return [];
1038
+ return [{ change, arm: changeArm(change.id, accountKey, change.holdout_pct), preview: false }];
1039
+ });
1040
+ }
1041
+ function resolvePreviewId(search, store) {
1042
+ let asked = null;
1043
+ try {
1044
+ asked = new URLSearchParams(search).get(PREVIEW_PARAM);
1045
+ } catch {
1046
+ asked = null;
1047
+ }
1048
+ try {
1049
+ if (asked !== null) {
1050
+ if (PREVIEW_ID.test(asked)) {
1051
+ store?.setItem(PREVIEW_KEY, asked);
1052
+ return asked;
1053
+ }
1054
+ store?.removeItem(PREVIEW_KEY);
1055
+ return null;
1056
+ }
1057
+ const kept = store?.getItem(PREVIEW_KEY) ?? null;
1058
+ return kept && PREVIEW_ID.test(kept) ? kept : null;
1059
+ } catch {
1060
+ return asked !== null && PREVIEW_ID.test(asked) ? asked : null;
1061
+ }
774
1062
  }
775
1063
  function storage(kind) {
776
1064
  try {
@@ -832,7 +1120,7 @@ function findTextNode(el, match) {
832
1120
  }
833
1121
  function installProductChanges(opts) {
834
1122
  if (typeof window === "undefined" || typeof document === "undefined") {
835
- return { onNavigate: () => void 0, refresh: async () => void 0, teardown: () => void 0 };
1123
+ return { onNavigate: () => void 0, onEvent: () => void 0, refresh: async () => void 0, teardown: () => void 0 };
836
1124
  }
837
1125
  let changes = [];
838
1126
  let lastFetch = 0;
@@ -841,6 +1129,11 @@ function installProductChanges(opts) {
841
1129
  let host = null;
842
1130
  const copies = /* @__PURE__ */ new Map();
843
1131
  const tips = /* @__PURE__ */ new Map();
1132
+ const progress = /* @__PURE__ */ new Map();
1133
+ const progressAt = /* @__PURE__ */ new Map();
1134
+ const lists = /* @__PURE__ */ new Map();
1135
+ let fetchedForKey = null;
1136
+ let previewId = resolvePreviewId(location.search, storage("session"));
844
1137
  const log = (message) => {
845
1138
  if (opts.debug) console.warn(`[holdy] ${message}`);
846
1139
  };
@@ -890,21 +1183,66 @@ function installProductChanges(opts) {
890
1183
  document.body.appendChild(host);
891
1184
  return root;
892
1185
  };
893
- const showTip = (anchor, change) => {
1186
+ let dismissedPreview = false;
1187
+ const previewTag = () => {
1188
+ const tag = document.createElement("span");
1189
+ tag.className = "preview";
1190
+ tag.textContent = "Pr\xE9via, s\xF3 voc\xEA v\xEA";
1191
+ return tag;
1192
+ };
1193
+ const showTip = (anchor, change, preview) => {
894
1194
  if (tips.has(change.id)) return true;
895
- if (readSet(storage("local"), DISMISSED_KEY).has(change.id)) return true;
1195
+ if (!preview && readSet(storage("local"), DISMISSED_KEY).has(change.id)) return true;
1196
+ const asking = change.kind === "question";
1197
+ if (!preview && asking && readSet(storage("local"), ANSWERED_KEY).has(change.id)) return true;
896
1198
  const shadow = ensureRoot();
897
1199
  if (!shadow) return false;
1200
+ if (host) applyThemeVars(host, readTheme(document, (el) => window.getComputedStyle(el), anchor));
898
1201
  const card = document.createElement("div");
899
- card.className = "tip";
900
- card.setAttribute("role", "tooltip");
1202
+ card.className = asking ? "tip ask" : "tip";
1203
+ card.setAttribute("role", asking ? "dialog" : "tooltip");
901
1204
  const text = document.createElement("p");
902
1205
  text.textContent = change.content.text;
903
1206
  const close = document.createElement("button");
904
1207
  close.type = "button";
905
1208
  close.setAttribute("aria-label", "Fechar");
906
1209
  close.textContent = "\xD7";
907
- card.append(text, close);
1210
+ if (asking) {
1211
+ const head = document.createElement("div");
1212
+ head.className = "head";
1213
+ head.append(text, close);
1214
+ const input = document.createElement("textarea");
1215
+ input.maxLength = MAX_ANSWER_CHARS;
1216
+ input.setAttribute("aria-label", change.content.text);
1217
+ const send = document.createElement("button");
1218
+ send.type = "button";
1219
+ send.className = "send";
1220
+ send.textContent = "Enviar";
1221
+ send.disabled = true;
1222
+ input.addEventListener("input", () => {
1223
+ send.disabled = input.value.trim().length === 0;
1224
+ });
1225
+ send.addEventListener("click", () => {
1226
+ const answer = input.value.trim().slice(0, MAX_ANSWER_CHARS);
1227
+ if (!answer) return;
1228
+ if (!preview) {
1229
+ addToSet(storage("local"), ANSWERED_KEY, change.id);
1230
+ opts.track(VOICE_EVENT, {
1231
+ question: change.content.text,
1232
+ text: answer,
1233
+ action_plan_id: change.plan_id,
1234
+ change_id: change.id,
1235
+ path: location.pathname
1236
+ });
1237
+ }
1238
+ card.replaceChildren(Object.assign(document.createElement("p"), { textContent: "Obrigado pela resposta." }));
1239
+ setTimeout(remove, THANKS_MS);
1240
+ });
1241
+ card.append(head, input, send);
1242
+ } else {
1243
+ card.append(text, close);
1244
+ }
1245
+ if (preview) card.prepend(previewTag());
908
1246
  card.style.top = "-9999px";
909
1247
  card.style.left = "-9999px";
910
1248
  shadow.appendChild(card);
@@ -934,6 +1272,11 @@ function installProductChanges(opts) {
934
1272
  card.style.left = `${Math.max(8, left)}px`;
935
1273
  }
936
1274
  close.addEventListener("click", () => {
1275
+ if (preview) {
1276
+ dismissedPreview = true;
1277
+ remove();
1278
+ return;
1279
+ }
937
1280
  addToSet(storage("local"), DISMISSED_KEY, change.id);
938
1281
  opts.track("$change_dismissed", { change_id: change.id, kind: change.kind });
939
1282
  remove();
@@ -944,20 +1287,143 @@ function installProductChanges(opts) {
944
1287
  window.addEventListener("resize", position);
945
1288
  return true;
946
1289
  };
1290
+ const doneOf = (change) => progress.get(change.id) ?? /* @__PURE__ */ new Set();
1291
+ const allDone = (change) => {
1292
+ const done = doneOf(change);
1293
+ return (change.content.items ?? []).every((i) => done.has(i.definition_name));
1294
+ };
1295
+ const fetchProgress = async (change) => {
1296
+ const account = opts.getAccountKey();
1297
+ if (!account) return;
1298
+ const last = progressAt.get(change.id) ?? 0;
1299
+ if (Date.now() - last < PROGRESS_REFRESH_MS) return;
1300
+ progressAt.set(change.id, Date.now());
1301
+ try {
1302
+ const res = await fetch(
1303
+ `${opts.baseUrl}/runtime/checklist?change_id=${encodeURIComponent(change.id)}&account=${encodeURIComponent(account)}`,
1304
+ { headers: { Authorization: `Bearer ${opts.apiKey}` } }
1305
+ );
1306
+ if (!res.ok) return;
1307
+ const body = await res.json();
1308
+ const done = Array.isArray(body.done) ? body.done.filter((d) => typeof d === "string") : [];
1309
+ const set = doneOf(change);
1310
+ for (const d of done) set.add(d);
1311
+ progress.set(change.id, set);
1312
+ lists.get(change.id)?.();
1313
+ } catch (err) {
1314
+ log(`checklist progress error: ${err.message}`);
1315
+ }
1316
+ };
1317
+ const showChecklist = (change, preview) => {
1318
+ if (lists.has(change.id)) return true;
1319
+ if (!preview && readSet(storage("local"), COMPLETED_KEY).has(change.id)) return true;
1320
+ const shadow = ensureRoot();
1321
+ if (!shadow) return false;
1322
+ const items = change.content.items ?? [];
1323
+ const card = document.createElement("div");
1324
+ card.className = "tip list";
1325
+ card.setAttribute("role", "region");
1326
+ card.setAttribute("aria-label", change.content.text);
1327
+ const head = document.createElement("div");
1328
+ head.className = "head";
1329
+ const title = document.createElement("p");
1330
+ const strong = document.createElement("strong");
1331
+ strong.textContent = change.content.text;
1332
+ const count = document.createElement("span");
1333
+ count.className = "count";
1334
+ title.append(strong, count);
1335
+ const fold = document.createElement("button");
1336
+ fold.type = "button";
1337
+ head.append(title, fold);
1338
+ const list = document.createElement("ul");
1339
+ card.append(head, list);
1340
+ if (preview) card.prepend(previewTag());
1341
+ card.style.position = "fixed";
1342
+ card.style.right = "16px";
1343
+ card.style.bottom = "16px";
1344
+ shadow.appendChild(card);
1345
+ const remove = () => {
1346
+ card.remove();
1347
+ lists.delete(change.id);
1348
+ };
1349
+ const render = () => {
1350
+ const done = doneOf(change);
1351
+ const n = items.filter((i) => done.has(i.definition_name)).length;
1352
+ count.textContent = `${n} de ${items.length}`;
1353
+ const folded = readSet(storage("local"), FOLDED_KEY).has(change.id);
1354
+ card.classList.toggle("folded", folded);
1355
+ fold.textContent = folded ? "+" : "\u2013";
1356
+ fold.setAttribute("aria-label", folded ? "Abrir" : "Recolher");
1357
+ list.replaceChildren(
1358
+ ...items.map((item) => {
1359
+ const li = document.createElement("li");
1360
+ const isDone = done.has(item.definition_name);
1361
+ if (isDone) li.className = "done";
1362
+ const mark = document.createElement("span");
1363
+ mark.className = "mark";
1364
+ mark.setAttribute("aria-hidden", "true");
1365
+ mark.textContent = isDone ? "\u2713" : "";
1366
+ const label = document.createElement("span");
1367
+ label.textContent = item.label;
1368
+ li.append(mark, label);
1369
+ return li;
1370
+ })
1371
+ );
1372
+ if (!preview && n === items.length && items.length > 0) {
1373
+ addToSet(storage("local"), COMPLETED_KEY, change.id);
1374
+ opts.track("$checklist_completed", { change_id: change.id });
1375
+ remove();
1376
+ }
1377
+ };
1378
+ fold.addEventListener("click", () => {
1379
+ const store = storage("local");
1380
+ const set = readSet(store, FOLDED_KEY);
1381
+ if (set.has(change.id)) set.delete(change.id);
1382
+ else set.add(change.id);
1383
+ try {
1384
+ store?.setItem(FOLDED_KEY, JSON.stringify([...set].slice(-200)));
1385
+ } catch {
1386
+ }
1387
+ render();
1388
+ });
1389
+ lists.set(change.id, render);
1390
+ render();
1391
+ void fetchProgress(change);
1392
+ return true;
1393
+ };
947
1394
  const scan = () => {
948
1395
  timer = null;
949
- const active = changesForPage(changes, location.pathname, opts.getAccountKey());
1396
+ const active = changesForPage(changes, location.pathname, opts.getAccountKey(), previewId);
950
1397
  const activeIds = new Set(active.map((a) => a.change.id));
951
1398
  for (const id of [...copies.keys()]) if (!activeIds.has(id)) restoreCopy(id);
952
1399
  for (const [id, remove] of [...tips]) if (!activeIds.has(id)) remove();
953
- for (const { change, arm } of active) {
1400
+ for (const [id, render] of [...lists]) {
1401
+ if (!activeIds.has(id)) {
1402
+ lists.delete(id);
1403
+ render();
1404
+ }
1405
+ }
1406
+ for (const { change, arm, preview } of active) {
954
1407
  const el = findAnchor(change.anchor);
955
- if (!el && change.anchor.kind !== "page") continue;
956
- if (change.kind === "copy") {
1408
+ if (!el && change.anchor.kind !== "page") {
1409
+ if (preview) log(`preview: \xE2ncora n\xE3o encontrada em ${location.pathname}`);
1410
+ continue;
1411
+ }
1412
+ if (preview) {
1413
+ if (change.kind === "checklist") showChecklist(change, true);
1414
+ else if (change.kind === "copy") {
1415
+ if (el) applyCopy(el, change);
1416
+ } else if (!dismissedPreview) showTip(el, change, true);
1417
+ continue;
1418
+ }
1419
+ if (change.kind === "checklist") {
1420
+ if (arm === "holdout" || allDone(change) || showChecklist(change, false)) expose(change, arm);
1421
+ if (arm === "change") void fetchProgress(change);
1422
+ } else if (change.kind === "copy") {
957
1423
  if (!el) continue;
958
1424
  const shown = arm === "change" ? applyCopy(el, change) : eligibleCopyNode(el, change) !== null;
959
1425
  if (shown) expose(change, arm);
960
- } else if (arm === "holdout" || showTip(el, change)) {
1426
+ } else if (arm === "holdout" || showTip(el, change, false)) {
961
1427
  expose(change, arm);
962
1428
  }
963
1429
  }
@@ -967,8 +1433,14 @@ function installProductChanges(opts) {
967
1433
  };
968
1434
  const refresh = async () => {
969
1435
  lastFetch = Date.now();
1436
+ const account = opts.getAccountKey();
1437
+ fetchedForKey = account;
1438
+ const params = new URLSearchParams();
1439
+ if (account) params.set("account", account);
1440
+ if (previewId) params.set("preview", previewId);
1441
+ const qs = params.toString();
970
1442
  try {
971
- const res = await fetch(`${opts.baseUrl}/runtime/changes`, {
1443
+ const res = await fetch(`${opts.baseUrl}/runtime/changes${qs ? `?${qs}` : ""}`, {
972
1444
  headers: { Authorization: `Bearer ${opts.apiKey}` }
973
1445
  });
974
1446
  if (!res.ok) {
@@ -993,9 +1465,28 @@ function installProductChanges(opts) {
993
1465
  void refresh();
994
1466
  return {
995
1467
  onNavigate: () => {
996
- if (Date.now() - lastFetch > NAV_REFRESH_MS) void refresh();
1468
+ const asked = resolvePreviewId(location.search, storage("session"));
1469
+ const previewChanged = asked !== previewId;
1470
+ if (previewChanged) {
1471
+ previewId = asked;
1472
+ dismissedPreview = false;
1473
+ for (const id of [...copies.keys()]) restoreCopy(id);
1474
+ for (const remove of [...tips.values()]) remove();
1475
+ }
1476
+ if (previewChanged || Date.now() - lastFetch > NAV_REFRESH_MS || opts.getAccountKey() !== fetchedForKey) void refresh();
997
1477
  schedule();
998
1478
  },
1479
+ onEvent: (name) => {
1480
+ for (const change of changes) {
1481
+ if (change.kind !== "checklist") continue;
1482
+ const hit = (change.content.items ?? []).filter((i) => i.events.includes(name));
1483
+ if (hit.length === 0) continue;
1484
+ const set = doneOf(change);
1485
+ for (const i of hit) set.add(i.definition_name);
1486
+ progress.set(change.id, set);
1487
+ lists.get(change.id)?.();
1488
+ }
1489
+ },
999
1490
  refresh,
1000
1491
  teardown: () => {
1001
1492
  clearInterval(interval);
@@ -1003,6 +1494,7 @@ function installProductChanges(opts) {
1003
1494
  observer?.disconnect();
1004
1495
  for (const id of [...copies.keys()]) restoreCopy(id);
1005
1496
  for (const remove of [...tips.values()]) remove();
1497
+ for (const id of [...lists.keys()]) lists.delete(id);
1006
1498
  host?.remove();
1007
1499
  host = null;
1008
1500
  root = null;
@@ -1017,6 +1509,7 @@ var HoldyWeb = class extends HoldyCore {
1017
1509
  teardownAutoCapture = null;
1018
1510
  friction = null;
1019
1511
  changes = null;
1512
+ skeleton = null;
1020
1513
  constructor(opts) {
1021
1514
  super("sdk_web", opts);
1022
1515
  this.anonymousId = resolveAnonymousId();
@@ -1034,12 +1527,19 @@ var HoldyWeb = class extends HoldyCore {
1034
1527
  onFriction: (signal) => this.track(`$${signal.kind}`, signal.properties)
1035
1528
  });
1036
1529
  }
1530
+ if (opts.skeleton !== false) {
1531
+ this.skeleton = installSkeletonCapture({
1532
+ onSkeleton: (skeleton) => this.track("$page_skeleton", { ...skeleton })
1533
+ });
1534
+ }
1037
1535
  if (opts.autoCapture !== false) {
1038
1536
  this.teardownAutoCapture = installAutoCapture({
1039
1537
  onNavigate: (kind) => {
1040
1538
  this.page(void 0, { navigation: kind });
1041
- this.friction?.onNavigate(typeof location !== "undefined" ? location.pathname : "/");
1539
+ const path = typeof location !== "undefined" ? location.pathname : "/";
1540
+ this.friction?.onNavigate(path);
1042
1541
  this.changes?.onNavigate();
1542
+ this.skeleton?.onNavigate(path);
1043
1543
  }
1044
1544
  });
1045
1545
  }
@@ -1071,6 +1571,10 @@ var HoldyWeb = class extends HoldyCore {
1071
1571
  super.group(accountKey, traits);
1072
1572
  this.changes?.onNavigate();
1073
1573
  }
1574
+ track(name, properties, overrides) {
1575
+ super.track(name, properties, overrides);
1576
+ if (!name.startsWith("$")) this.changes?.onEvent(name);
1577
+ }
1074
1578
  reset() {
1075
1579
  this.userId = null;
1076
1580
  this.accountKey = null;
@@ -1078,6 +1582,7 @@ var HoldyWeb = class extends HoldyCore {
1078
1582
  localStorage.removeItem(ANON_STORAGE_KEY);
1079
1583
  } catch {
1080
1584
  }
1585
+ clearSessionId();
1081
1586
  this.anonymousId = resolveAnonymousId();
1082
1587
  this.changes?.onNavigate();
1083
1588
  }
@@ -1089,6 +1594,8 @@ var HoldyWeb = class extends HoldyCore {
1089
1594
  this.friction = null;
1090
1595
  this.changes?.teardown();
1091
1596
  this.changes = null;
1597
+ this.skeleton?.teardown();
1598
+ this.skeleton = null;
1092
1599
  await super.shutdown();
1093
1600
  }
1094
1601
  environmentContext() {