@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/web.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;
@@ -290,11 +293,14 @@ var NavLoopDetector = class {
290
293
  return null;
291
294
  }
292
295
  };
296
+ function tagOf(el) {
297
+ return typeof el?.tagName === "string" ? el.tagName.toLowerCase() : "";
298
+ }
293
299
  function resolveInteractive(el) {
294
300
  if (!el) return null;
295
301
  const viaClosest = el.closest?.(INTERACTIVE_SELECTOR) ?? null;
296
302
  if (viaClosest) return viaClosest;
297
- const tag = el.tagName.toLowerCase();
303
+ const tag = tagOf(el);
298
304
  if (INTERACTIVE_TAGS.has(tag)) return el;
299
305
  const role = el.getAttribute("role");
300
306
  if (role === "button" || role === "link" || role === "menuitem" || role === "tab") return el;
@@ -306,7 +312,7 @@ function isDeadClickTarget(el) {
306
312
  if (target.disabled === true) return true;
307
313
  if (target.getAttribute("disabled") !== null) return true;
308
314
  if (target.getAttribute("aria-disabled") === "true") return true;
309
- const tag = target.tagName.toLowerCase();
315
+ const tag = tagOf(target);
310
316
  if (tag === "a") {
311
317
  const href = target.getAttribute("href");
312
318
  if (!href || href === "#" || href.startsWith("javascript:")) return true;
@@ -316,7 +322,7 @@ function isDeadClickTarget(el) {
316
322
  function describeElement(el) {
317
323
  const target = resolveInteractive(el) ?? el;
318
324
  if (!target) return {};
319
- const out = { element: target.tagName.toLowerCase() };
325
+ const out = { element: tagOf(target) };
320
326
  if (target.id) out.element_id = target.id;
321
327
  const role = target.getAttribute("role");
322
328
  if (role) out.element_role = role;
@@ -344,6 +350,22 @@ function errorMessage(reason) {
344
350
  return String(reason);
345
351
  }
346
352
  }
353
+ var STACK_MAX_FRAMES = 8;
354
+ var STACK_MAX_FRAME_CHARS = 200;
355
+ var STACK_FRAME = /^at\s|^\S*@\S+:\d+(?::\d+)?$/;
356
+ function sanitizeStack(stack) {
357
+ if (typeof stack !== "string" || !stack) return null;
358
+ const frames = stack.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && STACK_FRAME.test(line)).slice(0, STACK_MAX_FRAMES).map(
359
+ (line) => line.replace(/(https?:\/\/[^\s)?#]+)[?#][^\s)]*?(?=(?::\d+){0,2}\)?$)/, "$1").slice(0, STACK_MAX_FRAME_CHARS)
360
+ );
361
+ return frames.length > 0 ? frames.join("\n") : null;
362
+ }
363
+ function withStack(stack) {
364
+ return stack ? { stack } : {};
365
+ }
366
+ function stackOf(reason) {
367
+ return typeof reason === "object" && reason !== null ? sanitizeStack(reason.stack) : null;
368
+ }
347
369
  function isAbortError(err) {
348
370
  return typeof err === "object" && err !== null && err.name === "AbortError";
349
371
  }
@@ -421,13 +443,19 @@ function installFrictionCapture(opts) {
421
443
  emit("js_error", {
422
444
  message: String(ev.message ?? "").slice(0, 200),
423
445
  ...ev.filename ? { source: stripRequestUrl(ev.filename, location.href) } : {},
424
- ...ev.lineno ? { line: ev.lineno } : {}
446
+ ...ev.lineno ? { line: ev.lineno } : {},
447
+ ...ev.colno ? { column: ev.colno } : {},
448
+ ...withStack(stackOf(ev.error))
425
449
  });
426
450
  };
427
451
  window.addEventListener("error", onError);
428
452
  cleanups.push(() => window.removeEventListener("error", onError));
429
453
  const onRejection = (ev) => {
430
- emit("js_error", { message: errorMessage(ev.reason).slice(0, 200), unhandled_rejection: true });
454
+ emit("js_error", {
455
+ message: errorMessage(ev.reason).slice(0, 200),
456
+ unhandled_rejection: true,
457
+ ...withStack(stackOf(ev.reason))
458
+ });
431
459
  };
432
460
  window.addEventListener("unhandledrejection", onRejection);
433
461
  cleanups.push(() => window.removeEventListener("unhandledrejection", onRejection));
@@ -509,11 +537,11 @@ function installFrictionCapture(opts) {
509
537
  }
510
538
  const onFocusIn = (ev) => {
511
539
  const el = ev.target;
512
- if (!el || !FORM_CONTROL_TAGS.has(el.tagName.toLowerCase())) return;
540
+ if (!el || !FORM_CONTROL_TAGS.has(tagOf(el))) return;
513
541
  const form = el.form;
514
542
  if (!form) return;
515
543
  const entry = touched.get(form) ?? { form: formKey(form), fields: /* @__PURE__ */ new Set() };
516
- const field = el.name || el.id || el.tagName.toLowerCase();
544
+ const field = el.name || el.id || tagOf(el);
517
545
  entry.fields.add(field);
518
546
  touched.set(form, entry);
519
547
  };
@@ -546,6 +574,200 @@ function installFrictionCapture(opts) {
546
574
  };
547
575
  }
548
576
 
577
+ // src/theme.ts
578
+ var THEME_BUTTON_SELECTOR = 'button[type="submit"],[data-testid*="primary"],.btn-primary,button,[role="button"],a.button';
579
+ var THEME_MAX_BUTTONS = 40;
580
+ var THEME_KEYS = ["font", "size", "fg", "bg", "accent", "accentFg", "radius"];
581
+ var COLOR = /^(rgba?\([\d.,\s%/]{1,40}\)|#[0-9a-fA-F]{3,8}|(?:color|oklch|oklab|hsla?|lab|lch)\([^()]{1,60}\))$/;
582
+ var MIN_ACCENT_CONTRAST = 60;
583
+ var RGB = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/;
584
+ function colorDistance(a, b) {
585
+ const x = RGB.exec(a);
586
+ const y = b ? RGB.exec(b) : null;
587
+ if (!x) return MIN_ACCENT_CONTRAST + 1;
588
+ if (!y) return MIN_ACCENT_CONTRAST + 1;
589
+ return Math.abs(+x[1] - +y[1]) + Math.abs(+x[2] - +y[2]) + Math.abs(+x[3] - +y[3]);
590
+ }
591
+ 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*\))$/;
592
+ function themeVarName(key) {
593
+ return `--holdy-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
594
+ }
595
+ function cleanColor(value) {
596
+ const v = (value ?? "").trim();
597
+ return v && v.length <= 80 && !TRANSPARENT.test(v) && COLOR.test(v) ? v : void 0;
598
+ }
599
+ function cleanFont(value) {
600
+ const first = (value ?? "").split(",")[0].trim().replace(/^["']|["']$/g, "").trim();
601
+ return /^[\w\s-]{1,60}$/.test(first) ? first : void 0;
602
+ }
603
+ function cleanPx(value, min, max) {
604
+ const m = /^(\d+(?:\.\d+)?)px/.exec((value ?? "").trim());
605
+ const n = m ? Math.round(Number(m[1])) : NaN;
606
+ return n >= min && n <= max ? `${n}px` : void 0;
607
+ }
608
+ function visible(el) {
609
+ const r = el.getBoundingClientRect?.();
610
+ return !r || r.width > 0 && r.height > 0;
611
+ }
612
+ function readTheme(doc, getStyle, anchor = null) {
613
+ const body = doc.body;
614
+ if (!body) return null;
615
+ const bodyStyle = getStyle(body);
616
+ const theme = {};
617
+ const put = (key, value) => {
618
+ if (value) theme[key] = value;
619
+ };
620
+ put("font", cleanFont(bodyStyle.getPropertyValue("font-family")));
621
+ put("size", cleanPx(bodyStyle.getPropertyValue("font-size"), 10, 20));
622
+ put("fg", cleanColor(bodyStyle.getPropertyValue("color")));
623
+ put("bg", cleanColor(bodyStyle.getPropertyValue("background-color")) ?? cleanColor(getStyle(doc.documentElement).getPropertyValue("background-color")));
624
+ const candidates = doc.querySelectorAll(THEME_BUTTON_SELECTOR);
625
+ let button = null;
626
+ let best = MIN_ACCENT_CONTRAST;
627
+ for (let i = 0; i < candidates.length && i < THEME_MAX_BUTTONS; i++) {
628
+ const el = candidates[i];
629
+ if (!visible(el)) continue;
630
+ const s = getStyle(el);
631
+ const accent = cleanColor(s.getPropertyValue("background-color"));
632
+ if (!accent || accent === theme.bg) continue;
633
+ const contrast = colorDistance(accent, theme.bg);
634
+ if (contrast <= best) continue;
635
+ best = contrast;
636
+ put("accent", accent);
637
+ put("accentFg", cleanColor(s.getPropertyValue("color")));
638
+ button = el;
639
+ }
640
+ const radiusSource = anchor && anchor.tagName.toLowerCase() === "button" ? anchor : button;
641
+ if (radiusSource) put("radius", cleanPx(getStyle(radiusSource).getPropertyValue("border-top-left-radius"), 0, 24));
642
+ return Object.keys(theme).length > 0 ? theme : null;
643
+ }
644
+ function themeVars(theme) {
645
+ const out = [];
646
+ for (const key of THEME_KEYS) {
647
+ const value = theme?.[key];
648
+ if (value) out.push([themeVarName(key), value]);
649
+ }
650
+ return out;
651
+ }
652
+ function applyThemeVars(el, theme) {
653
+ for (const key of THEME_KEYS) el.style.removeProperty(themeVarName(key));
654
+ for (const [name, value] of themeVars(theme)) el.style.setProperty(name, value);
655
+ }
656
+
657
+ // src/skeleton.ts
658
+ 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]';
659
+ var SKELETON_MAX_ELEMENTS = 80;
660
+ var SKELETON_DELAY_MS = 1500;
661
+ var SENT_STORAGE_KEY = "holdy_skeletons";
662
+ var SENT_STORAGE_MAX = 200;
663
+ function fnv1a(input) {
664
+ let hash = 2166136261;
665
+ for (let i = 0; i < input.length; i++) {
666
+ hash ^= input.charCodeAt(i);
667
+ hash = Math.imul(hash, 16777619) >>> 0;
668
+ }
669
+ return hash.toString(16).padStart(8, "0");
670
+ }
671
+ function attr(el, name, max) {
672
+ const value = el.getAttribute(name);
673
+ if (!value) return void 0;
674
+ const trimmed = value.replace(/\s+/g, " ").trim().slice(0, max);
675
+ return trimmed || void 0;
676
+ }
677
+ function isVisible(el) {
678
+ if (!el.getBoundingClientRect) return true;
679
+ const rect = el.getBoundingClientRect();
680
+ return rect.width > 0 && rect.height > 0;
681
+ }
682
+ function relativeBox(rect, viewport) {
683
+ const pct = (value, base) => base > 0 ? Math.max(0, Math.min(100, Math.round(value / base * 100))) : 0;
684
+ return [pct(rect.x, viewport.width), pct(rect.y + viewport.scrollY, viewport.height), pct(rect.width, viewport.width), pct(rect.height, viewport.height)];
685
+ }
686
+ function describeSkeletonElement(el, viewport) {
687
+ const out = { t: el.tagName.toLowerCase() };
688
+ const role = attr(el, "role", 40);
689
+ if (role) out.r = role;
690
+ const testid = attr(el, "data-testid", 80);
691
+ if (testid) out.id = testid;
692
+ const name = attr(el, "name", 80);
693
+ if (name) out.n = name;
694
+ const label = attr(el, "aria-label", 80);
695
+ if (label) out.l = label;
696
+ if (out.t === "input" || out.t === "button") {
697
+ const type = attr(el, "type", 20);
698
+ if (type) out.k = type.toLowerCase();
699
+ }
700
+ if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport);
701
+ return out;
702
+ }
703
+ function collectSkeleton(root, path, viewport, theme = null) {
704
+ const found = root.querySelectorAll(SKELETON_SELECTOR);
705
+ const visible2 = [];
706
+ for (let i = 0; i < found.length; i++) {
707
+ const el = found[i];
708
+ if (el.tagName.toLowerCase() === "input" && (el.getAttribute("type") ?? "").toLowerCase() === "hidden") continue;
709
+ if (!isVisible(el)) continue;
710
+ visible2.push(el);
711
+ }
712
+ const elements = visible2.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
713
+ const structural = elements.map(({ b: _b, ...rest }) => rest);
714
+ return { path, hash: fnv1a(JSON.stringify(structural)), count: visible2.length, elements, ...theme ? { theme } : {} };
715
+ }
716
+ function loadSent() {
717
+ try {
718
+ const raw = sessionStorage.getItem(SENT_STORAGE_KEY);
719
+ const list = raw ? JSON.parse(raw) : [];
720
+ return new Set(Array.isArray(list) ? list.filter((x) => typeof x === "string") : []);
721
+ } catch {
722
+ return /* @__PURE__ */ new Set();
723
+ }
724
+ }
725
+ function saveSent(sent) {
726
+ try {
727
+ sessionStorage.setItem(SENT_STORAGE_KEY, JSON.stringify([...sent].slice(-SENT_STORAGE_MAX)));
728
+ } catch {
729
+ return;
730
+ }
731
+ }
732
+ function installSkeletonCapture(opts) {
733
+ if (typeof window === "undefined" || typeof document === "undefined") {
734
+ return { onNavigate: () => void 0, teardown: () => void 0 };
735
+ }
736
+ const delay = opts.delayMs ?? SKELETON_DELAY_MS;
737
+ const sent = loadSent();
738
+ let pending = null;
739
+ const capture = (path) => {
740
+ pending = null;
741
+ if (location.pathname !== path) return;
742
+ try {
743
+ const viewport = {
744
+ width: window.innerWidth,
745
+ height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
746
+ scrollY: window.scrollY
747
+ };
748
+ const theme = readTheme(document, (el) => window.getComputedStyle(el));
749
+ const skeleton = collectSkeleton(document, path, viewport, theme);
750
+ const key = `${path}:${skeleton.hash}`;
751
+ if (sent.has(key)) return;
752
+ sent.add(key);
753
+ saveSent(sent);
754
+ opts.onSkeleton(skeleton);
755
+ } catch {
756
+ return;
757
+ }
758
+ };
759
+ return {
760
+ onNavigate: (path) => {
761
+ if (pending) clearTimeout(pending);
762
+ pending = setTimeout(() => capture(path), delay);
763
+ },
764
+ teardown: () => {
765
+ if (pending) clearTimeout(pending);
766
+ pending = null;
767
+ }
768
+ };
769
+ }
770
+
549
771
  // src/web-context.ts
550
772
  var UTM_KEYS = [
551
773
  "utm_source",
@@ -610,6 +832,12 @@ function randomId() {
610
832
  return `hol-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
611
833
  }
612
834
  }
835
+ function clearSessionId() {
836
+ try {
837
+ sessionStorage.removeItem(SESSION_STORAGE_KEY);
838
+ } catch {
839
+ }
840
+ }
613
841
  function resolveSessionId() {
614
842
  const existing = readStorage("session", SESSION_STORAGE_KEY);
615
843
  if (existing) return existing;
@@ -663,8 +891,35 @@ function buildWebContext() {
663
891
  };
664
892
  }
665
893
 
894
+ // src/tip-css.ts
895
+ var TIP_CSS = `:host{all:initial;--h-bg:#111;--h-fg:#fff;--h-accent:#fff;--h-accent-fg:#111;--h-border:transparent}
896
+ @media (prefers-color-scheme:dark){:host{--h-bg:#fff;--h-fg:#111;--h-accent:#111;--h-accent-fg:#fff}}
897
+ .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}
898
+ .tip p{margin:0;flex:1}
899
+ .tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1;color:inherit}
900
+ .tip button:hover,.tip button:focus-visible{opacity:1}
901
+ .ask{flex-direction:column;width:300px;max-width:calc(100vw - 16px);gap:8px}
902
+ .ask .head{display:flex;gap:10px;align-items:flex-start;width:100%}
903
+ .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}
904
+ .ask textarea:focus-visible{outline:2px solid color-mix(in srgb,var(--holdy-fg,var(--h-fg)) 45%,transparent)}
905
+ .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}
906
+ .ask .send[disabled]{opacity:.4;cursor:default}
907
+ .list{flex-direction:column;width:300px;max-width:calc(100vw - 16px);gap:8px}
908
+ .list .head{display:flex;gap:10px;align-items:flex-start;width:100%}
909
+ .list .head p{display:flex;justify-content:space-between;gap:10px;align-items:baseline}
910
+ .list .count{opacity:.7;font-size:12px;white-space:nowrap}
911
+ .list ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px;width:100%}
912
+ .list li{display:flex;gap:8px;align-items:flex-start}
913
+ .list li.done{opacity:.55;text-decoration:line-through}
914
+ .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}
915
+ .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))}
916
+ .list.folded ul{display:none}
917
+ .list.folded{width:auto}
918
+ .preview{flex:none;order:-1;width:100%;font-size:11px;opacity:.7}
919
+ .tip:has(.preview){flex-wrap:wrap;row-gap:4px}`;
920
+
666
921
  // src/arm.ts
667
- function fnv1a(input) {
922
+ function fnv1a2(input) {
668
923
  let hash = 2166136261;
669
924
  for (let i = 0; i < input.length; i++) {
670
925
  hash ^= input.charCodeAt(i);
@@ -673,7 +928,7 @@ function fnv1a(input) {
673
928
  return hash >>> 0;
674
929
  }
675
930
  function changeBucket(changeId, accountKey) {
676
- return fnv1a(`${changeId}:${accountKey}`) % 100;
931
+ return fnv1a2(`${changeId}:${accountKey}`) % 100;
677
932
  }
678
933
  function changeArm(changeId, accountKey, holdoutPct) {
679
934
  return changeBucket(changeId, accountKey) < holdoutPct ? "holdout" : "change";
@@ -692,12 +947,16 @@ var SCAN_DEBOUNCE_MS = 250;
692
947
  var SEEN_KEY = "holdy_changes_seen";
693
948
  var DISMISSED_KEY = "holdy_changes_dismissed";
694
949
  var TIP_GAP_PX = 8;
695
- var TIP_CSS = `:host{all:initial}
696
- .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}
697
- .tip p{margin:0;flex:1}
698
- .tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1}
699
- .tip button:hover,.tip button:focus-visible{opacity:1}
700
- @media (prefers-color-scheme:dark){.tip{background:#fff;color:#111}}`;
950
+ var ANSWERED_KEY = "holdy_questions_answered";
951
+ var FOLDED_KEY = "holdy_checklist_folded";
952
+ var COMPLETED_KEY = "holdy_checklist_completed";
953
+ var PROGRESS_REFRESH_MS = 60 * 1e3;
954
+ var VOICE_EVENT = "$voice";
955
+ var MAX_ANSWER_CHARS = 1e3;
956
+ var THANKS_MS = 1600;
957
+ var PREVIEW_PARAM = "holdy_preview";
958
+ var PREVIEW_KEY = "holdy_preview";
959
+ var PREVIEW_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
701
960
  function normalizeText(text) {
702
961
  return text.replace(/\s+/g, " ").trim();
703
962
  }
@@ -710,13 +969,42 @@ function isAnchor(value) {
710
969
  if (a?.kind === "page") return true;
711
970
  return (a?.kind === "selector" || a?.kind === "text") && typeof a.value === "string" && a.value.length > 0;
712
971
  }
972
+ function isItem(value) {
973
+ const i = value;
974
+ return typeof i?.definition_name === "string" && typeof i.label === "string" && Array.isArray(i.events) && i.events.every((e) => typeof e === "string");
975
+ }
713
976
  function isChange(value) {
714
977
  const c = value;
715
- 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";
978
+ 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";
716
979
  }
717
- function changesForPage(changes, path, accountKey) {
718
- if (!accountKey) return [];
719
- return changes.filter((c) => matchesPathTemplate(c.path, path)).map((change) => ({ change, arm: changeArm(change.id, accountKey, change.holdout_pct) }));
980
+ function changesForPage(changes, path, accountKey, previewId = null) {
981
+ return changes.filter((c) => c.kind === "checklist" || matchesPathTemplate(c.path, path)).flatMap((change) => {
982
+ if (change.id === previewId) return [{ change, arm: "change", preview: true }];
983
+ if (!accountKey) return [];
984
+ return [{ change, arm: changeArm(change.id, accountKey, change.holdout_pct), preview: false }];
985
+ });
986
+ }
987
+ function resolvePreviewId(search, store) {
988
+ let asked = null;
989
+ try {
990
+ asked = new URLSearchParams(search).get(PREVIEW_PARAM);
991
+ } catch {
992
+ asked = null;
993
+ }
994
+ try {
995
+ if (asked !== null) {
996
+ if (PREVIEW_ID.test(asked)) {
997
+ store?.setItem(PREVIEW_KEY, asked);
998
+ return asked;
999
+ }
1000
+ store?.removeItem(PREVIEW_KEY);
1001
+ return null;
1002
+ }
1003
+ const kept = store?.getItem(PREVIEW_KEY) ?? null;
1004
+ return kept && PREVIEW_ID.test(kept) ? kept : null;
1005
+ } catch {
1006
+ return asked !== null && PREVIEW_ID.test(asked) ? asked : null;
1007
+ }
720
1008
  }
721
1009
  function storage(kind) {
722
1010
  try {
@@ -778,7 +1066,7 @@ function findTextNode(el, match) {
778
1066
  }
779
1067
  function installProductChanges(opts) {
780
1068
  if (typeof window === "undefined" || typeof document === "undefined") {
781
- return { onNavigate: () => void 0, refresh: async () => void 0, teardown: () => void 0 };
1069
+ return { onNavigate: () => void 0, onEvent: () => void 0, refresh: async () => void 0, teardown: () => void 0 };
782
1070
  }
783
1071
  let changes = [];
784
1072
  let lastFetch = 0;
@@ -787,6 +1075,11 @@ function installProductChanges(opts) {
787
1075
  let host = null;
788
1076
  const copies = /* @__PURE__ */ new Map();
789
1077
  const tips = /* @__PURE__ */ new Map();
1078
+ const progress = /* @__PURE__ */ new Map();
1079
+ const progressAt = /* @__PURE__ */ new Map();
1080
+ const lists = /* @__PURE__ */ new Map();
1081
+ let fetchedForKey = null;
1082
+ let previewId = resolvePreviewId(location.search, storage("session"));
790
1083
  const log = (message) => {
791
1084
  if (opts.debug) console.warn(`[holdy] ${message}`);
792
1085
  };
@@ -836,21 +1129,66 @@ function installProductChanges(opts) {
836
1129
  document.body.appendChild(host);
837
1130
  return root;
838
1131
  };
839
- const showTip = (anchor, change) => {
1132
+ let dismissedPreview = false;
1133
+ const previewTag = () => {
1134
+ const tag = document.createElement("span");
1135
+ tag.className = "preview";
1136
+ tag.textContent = "Pr\xE9via, s\xF3 voc\xEA v\xEA";
1137
+ return tag;
1138
+ };
1139
+ const showTip = (anchor, change, preview) => {
840
1140
  if (tips.has(change.id)) return true;
841
- if (readSet(storage("local"), DISMISSED_KEY).has(change.id)) return true;
1141
+ if (!preview && readSet(storage("local"), DISMISSED_KEY).has(change.id)) return true;
1142
+ const asking = change.kind === "question";
1143
+ if (!preview && asking && readSet(storage("local"), ANSWERED_KEY).has(change.id)) return true;
842
1144
  const shadow = ensureRoot();
843
1145
  if (!shadow) return false;
1146
+ if (host) applyThemeVars(host, readTheme(document, (el) => window.getComputedStyle(el), anchor));
844
1147
  const card = document.createElement("div");
845
- card.className = "tip";
846
- card.setAttribute("role", "tooltip");
1148
+ card.className = asking ? "tip ask" : "tip";
1149
+ card.setAttribute("role", asking ? "dialog" : "tooltip");
847
1150
  const text = document.createElement("p");
848
1151
  text.textContent = change.content.text;
849
1152
  const close = document.createElement("button");
850
1153
  close.type = "button";
851
1154
  close.setAttribute("aria-label", "Fechar");
852
1155
  close.textContent = "\xD7";
853
- card.append(text, close);
1156
+ if (asking) {
1157
+ const head = document.createElement("div");
1158
+ head.className = "head";
1159
+ head.append(text, close);
1160
+ const input = document.createElement("textarea");
1161
+ input.maxLength = MAX_ANSWER_CHARS;
1162
+ input.setAttribute("aria-label", change.content.text);
1163
+ const send = document.createElement("button");
1164
+ send.type = "button";
1165
+ send.className = "send";
1166
+ send.textContent = "Enviar";
1167
+ send.disabled = true;
1168
+ input.addEventListener("input", () => {
1169
+ send.disabled = input.value.trim().length === 0;
1170
+ });
1171
+ send.addEventListener("click", () => {
1172
+ const answer = input.value.trim().slice(0, MAX_ANSWER_CHARS);
1173
+ if (!answer) return;
1174
+ if (!preview) {
1175
+ addToSet(storage("local"), ANSWERED_KEY, change.id);
1176
+ opts.track(VOICE_EVENT, {
1177
+ question: change.content.text,
1178
+ text: answer,
1179
+ action_plan_id: change.plan_id,
1180
+ change_id: change.id,
1181
+ path: location.pathname
1182
+ });
1183
+ }
1184
+ card.replaceChildren(Object.assign(document.createElement("p"), { textContent: "Obrigado pela resposta." }));
1185
+ setTimeout(remove, THANKS_MS);
1186
+ });
1187
+ card.append(head, input, send);
1188
+ } else {
1189
+ card.append(text, close);
1190
+ }
1191
+ if (preview) card.prepend(previewTag());
854
1192
  card.style.top = "-9999px";
855
1193
  card.style.left = "-9999px";
856
1194
  shadow.appendChild(card);
@@ -880,6 +1218,11 @@ function installProductChanges(opts) {
880
1218
  card.style.left = `${Math.max(8, left)}px`;
881
1219
  }
882
1220
  close.addEventListener("click", () => {
1221
+ if (preview) {
1222
+ dismissedPreview = true;
1223
+ remove();
1224
+ return;
1225
+ }
883
1226
  addToSet(storage("local"), DISMISSED_KEY, change.id);
884
1227
  opts.track("$change_dismissed", { change_id: change.id, kind: change.kind });
885
1228
  remove();
@@ -890,20 +1233,143 @@ function installProductChanges(opts) {
890
1233
  window.addEventListener("resize", position);
891
1234
  return true;
892
1235
  };
1236
+ const doneOf = (change) => progress.get(change.id) ?? /* @__PURE__ */ new Set();
1237
+ const allDone = (change) => {
1238
+ const done = doneOf(change);
1239
+ return (change.content.items ?? []).every((i) => done.has(i.definition_name));
1240
+ };
1241
+ const fetchProgress = async (change) => {
1242
+ const account = opts.getAccountKey();
1243
+ if (!account) return;
1244
+ const last = progressAt.get(change.id) ?? 0;
1245
+ if (Date.now() - last < PROGRESS_REFRESH_MS) return;
1246
+ progressAt.set(change.id, Date.now());
1247
+ try {
1248
+ const res = await fetch(
1249
+ `${opts.baseUrl}/runtime/checklist?change_id=${encodeURIComponent(change.id)}&account=${encodeURIComponent(account)}`,
1250
+ { headers: { Authorization: `Bearer ${opts.apiKey}` } }
1251
+ );
1252
+ if (!res.ok) return;
1253
+ const body = await res.json();
1254
+ const done = Array.isArray(body.done) ? body.done.filter((d) => typeof d === "string") : [];
1255
+ const set = doneOf(change);
1256
+ for (const d of done) set.add(d);
1257
+ progress.set(change.id, set);
1258
+ lists.get(change.id)?.();
1259
+ } catch (err) {
1260
+ log(`checklist progress error: ${err.message}`);
1261
+ }
1262
+ };
1263
+ const showChecklist = (change, preview) => {
1264
+ if (lists.has(change.id)) return true;
1265
+ if (!preview && readSet(storage("local"), COMPLETED_KEY).has(change.id)) return true;
1266
+ const shadow = ensureRoot();
1267
+ if (!shadow) return false;
1268
+ const items = change.content.items ?? [];
1269
+ const card = document.createElement("div");
1270
+ card.className = "tip list";
1271
+ card.setAttribute("role", "region");
1272
+ card.setAttribute("aria-label", change.content.text);
1273
+ const head = document.createElement("div");
1274
+ head.className = "head";
1275
+ const title = document.createElement("p");
1276
+ const strong = document.createElement("strong");
1277
+ strong.textContent = change.content.text;
1278
+ const count = document.createElement("span");
1279
+ count.className = "count";
1280
+ title.append(strong, count);
1281
+ const fold = document.createElement("button");
1282
+ fold.type = "button";
1283
+ head.append(title, fold);
1284
+ const list = document.createElement("ul");
1285
+ card.append(head, list);
1286
+ if (preview) card.prepend(previewTag());
1287
+ card.style.position = "fixed";
1288
+ card.style.right = "16px";
1289
+ card.style.bottom = "16px";
1290
+ shadow.appendChild(card);
1291
+ const remove = () => {
1292
+ card.remove();
1293
+ lists.delete(change.id);
1294
+ };
1295
+ const render = () => {
1296
+ const done = doneOf(change);
1297
+ const n = items.filter((i) => done.has(i.definition_name)).length;
1298
+ count.textContent = `${n} de ${items.length}`;
1299
+ const folded = readSet(storage("local"), FOLDED_KEY).has(change.id);
1300
+ card.classList.toggle("folded", folded);
1301
+ fold.textContent = folded ? "+" : "\u2013";
1302
+ fold.setAttribute("aria-label", folded ? "Abrir" : "Recolher");
1303
+ list.replaceChildren(
1304
+ ...items.map((item) => {
1305
+ const li = document.createElement("li");
1306
+ const isDone = done.has(item.definition_name);
1307
+ if (isDone) li.className = "done";
1308
+ const mark = document.createElement("span");
1309
+ mark.className = "mark";
1310
+ mark.setAttribute("aria-hidden", "true");
1311
+ mark.textContent = isDone ? "\u2713" : "";
1312
+ const label = document.createElement("span");
1313
+ label.textContent = item.label;
1314
+ li.append(mark, label);
1315
+ return li;
1316
+ })
1317
+ );
1318
+ if (!preview && n === items.length && items.length > 0) {
1319
+ addToSet(storage("local"), COMPLETED_KEY, change.id);
1320
+ opts.track("$checklist_completed", { change_id: change.id });
1321
+ remove();
1322
+ }
1323
+ };
1324
+ fold.addEventListener("click", () => {
1325
+ const store = storage("local");
1326
+ const set = readSet(store, FOLDED_KEY);
1327
+ if (set.has(change.id)) set.delete(change.id);
1328
+ else set.add(change.id);
1329
+ try {
1330
+ store?.setItem(FOLDED_KEY, JSON.stringify([...set].slice(-200)));
1331
+ } catch {
1332
+ }
1333
+ render();
1334
+ });
1335
+ lists.set(change.id, render);
1336
+ render();
1337
+ void fetchProgress(change);
1338
+ return true;
1339
+ };
893
1340
  const scan = () => {
894
1341
  timer = null;
895
- const active = changesForPage(changes, location.pathname, opts.getAccountKey());
1342
+ const active = changesForPage(changes, location.pathname, opts.getAccountKey(), previewId);
896
1343
  const activeIds = new Set(active.map((a) => a.change.id));
897
1344
  for (const id of [...copies.keys()]) if (!activeIds.has(id)) restoreCopy(id);
898
1345
  for (const [id, remove] of [...tips]) if (!activeIds.has(id)) remove();
899
- for (const { change, arm } of active) {
1346
+ for (const [id, render] of [...lists]) {
1347
+ if (!activeIds.has(id)) {
1348
+ lists.delete(id);
1349
+ render();
1350
+ }
1351
+ }
1352
+ for (const { change, arm, preview } of active) {
900
1353
  const el = findAnchor(change.anchor);
901
- if (!el && change.anchor.kind !== "page") continue;
902
- if (change.kind === "copy") {
1354
+ if (!el && change.anchor.kind !== "page") {
1355
+ if (preview) log(`preview: \xE2ncora n\xE3o encontrada em ${location.pathname}`);
1356
+ continue;
1357
+ }
1358
+ if (preview) {
1359
+ if (change.kind === "checklist") showChecklist(change, true);
1360
+ else if (change.kind === "copy") {
1361
+ if (el) applyCopy(el, change);
1362
+ } else if (!dismissedPreview) showTip(el, change, true);
1363
+ continue;
1364
+ }
1365
+ if (change.kind === "checklist") {
1366
+ if (arm === "holdout" || allDone(change) || showChecklist(change, false)) expose(change, arm);
1367
+ if (arm === "change") void fetchProgress(change);
1368
+ } else if (change.kind === "copy") {
903
1369
  if (!el) continue;
904
1370
  const shown = arm === "change" ? applyCopy(el, change) : eligibleCopyNode(el, change) !== null;
905
1371
  if (shown) expose(change, arm);
906
- } else if (arm === "holdout" || showTip(el, change)) {
1372
+ } else if (arm === "holdout" || showTip(el, change, false)) {
907
1373
  expose(change, arm);
908
1374
  }
909
1375
  }
@@ -913,8 +1379,14 @@ function installProductChanges(opts) {
913
1379
  };
914
1380
  const refresh = async () => {
915
1381
  lastFetch = Date.now();
1382
+ const account = opts.getAccountKey();
1383
+ fetchedForKey = account;
1384
+ const params = new URLSearchParams();
1385
+ if (account) params.set("account", account);
1386
+ if (previewId) params.set("preview", previewId);
1387
+ const qs = params.toString();
916
1388
  try {
917
- const res = await fetch(`${opts.baseUrl}/runtime/changes`, {
1389
+ const res = await fetch(`${opts.baseUrl}/runtime/changes${qs ? `?${qs}` : ""}`, {
918
1390
  headers: { Authorization: `Bearer ${opts.apiKey}` }
919
1391
  });
920
1392
  if (!res.ok) {
@@ -939,9 +1411,28 @@ function installProductChanges(opts) {
939
1411
  void refresh();
940
1412
  return {
941
1413
  onNavigate: () => {
942
- if (Date.now() - lastFetch > NAV_REFRESH_MS) void refresh();
1414
+ const asked = resolvePreviewId(location.search, storage("session"));
1415
+ const previewChanged = asked !== previewId;
1416
+ if (previewChanged) {
1417
+ previewId = asked;
1418
+ dismissedPreview = false;
1419
+ for (const id of [...copies.keys()]) restoreCopy(id);
1420
+ for (const remove of [...tips.values()]) remove();
1421
+ }
1422
+ if (previewChanged || Date.now() - lastFetch > NAV_REFRESH_MS || opts.getAccountKey() !== fetchedForKey) void refresh();
943
1423
  schedule();
944
1424
  },
1425
+ onEvent: (name) => {
1426
+ for (const change of changes) {
1427
+ if (change.kind !== "checklist") continue;
1428
+ const hit = (change.content.items ?? []).filter((i) => i.events.includes(name));
1429
+ if (hit.length === 0) continue;
1430
+ const set = doneOf(change);
1431
+ for (const i of hit) set.add(i.definition_name);
1432
+ progress.set(change.id, set);
1433
+ lists.get(change.id)?.();
1434
+ }
1435
+ },
945
1436
  refresh,
946
1437
  teardown: () => {
947
1438
  clearInterval(interval);
@@ -949,6 +1440,7 @@ function installProductChanges(opts) {
949
1440
  observer?.disconnect();
950
1441
  for (const id of [...copies.keys()]) restoreCopy(id);
951
1442
  for (const remove of [...tips.values()]) remove();
1443
+ for (const id of [...lists.keys()]) lists.delete(id);
952
1444
  host?.remove();
953
1445
  host = null;
954
1446
  root = null;
@@ -963,6 +1455,7 @@ var HoldyWeb = class extends HoldyCore {
963
1455
  teardownAutoCapture = null;
964
1456
  friction = null;
965
1457
  changes = null;
1458
+ skeleton = null;
966
1459
  constructor(opts) {
967
1460
  super("sdk_web", opts);
968
1461
  this.anonymousId = resolveAnonymousId();
@@ -980,12 +1473,19 @@ var HoldyWeb = class extends HoldyCore {
980
1473
  onFriction: (signal) => this.track(`$${signal.kind}`, signal.properties)
981
1474
  });
982
1475
  }
1476
+ if (opts.skeleton !== false) {
1477
+ this.skeleton = installSkeletonCapture({
1478
+ onSkeleton: (skeleton) => this.track("$page_skeleton", { ...skeleton })
1479
+ });
1480
+ }
983
1481
  if (opts.autoCapture !== false) {
984
1482
  this.teardownAutoCapture = installAutoCapture({
985
1483
  onNavigate: (kind) => {
986
1484
  this.page(void 0, { navigation: kind });
987
- this.friction?.onNavigate(typeof location !== "undefined" ? location.pathname : "/");
1485
+ const path = typeof location !== "undefined" ? location.pathname : "/";
1486
+ this.friction?.onNavigate(path);
988
1487
  this.changes?.onNavigate();
1488
+ this.skeleton?.onNavigate(path);
989
1489
  }
990
1490
  });
991
1491
  }
@@ -1017,6 +1517,10 @@ var HoldyWeb = class extends HoldyCore {
1017
1517
  super.group(accountKey, traits);
1018
1518
  this.changes?.onNavigate();
1019
1519
  }
1520
+ track(name, properties, overrides) {
1521
+ super.track(name, properties, overrides);
1522
+ if (!name.startsWith("$")) this.changes?.onEvent(name);
1523
+ }
1020
1524
  reset() {
1021
1525
  this.userId = null;
1022
1526
  this.accountKey = null;
@@ -1024,6 +1528,7 @@ var HoldyWeb = class extends HoldyCore {
1024
1528
  localStorage.removeItem(ANON_STORAGE_KEY);
1025
1529
  } catch {
1026
1530
  }
1531
+ clearSessionId();
1027
1532
  this.anonymousId = resolveAnonymousId();
1028
1533
  this.changes?.onNavigate();
1029
1534
  }
@@ -1035,6 +1540,8 @@ var HoldyWeb = class extends HoldyCore {
1035
1540
  this.friction = null;
1036
1541
  this.changes?.teardown();
1037
1542
  this.changes = null;
1543
+ this.skeleton?.teardown();
1544
+ this.skeleton = null;
1038
1545
  await super.shutdown();
1039
1546
  }
1040
1547
  environmentContext() {