@holdyapp/sdk 1.6.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 (48) hide show
  1. package/README.md +8 -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 +420 -36
  11. package/dist/index.cjs.map +4 -4
  12. package/dist/index.mjs +420 -36
  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 +3 -1
  19. package/dist/skeleton.d.ts.map +1 -1
  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 +2 -1
  29. package/dist/web.d.ts.map +1 -1
  30. package/dist/web.mjs +420 -36
  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.ts +12 -3
  42. package/src/theme.test.ts +118 -0
  43. package/src/theme.ts +120 -0
  44. package/src/tip-css.ts +32 -0
  45. package/src/types.ts +6 -0
  46. package/src/web-context.test.ts +18 -0
  47. package/src/web-context.ts +8 -0
  48. package/src/web.ts +8 -2
package/dist/index.cjs CHANGED
@@ -41,6 +41,7 @@ var HoldyCore = class {
41
41
  flushAt;
42
42
  debug;
43
43
  source;
44
+ release;
44
45
  queue = [];
45
46
  timer = null;
46
47
  flushing = null;
@@ -57,6 +58,7 @@ var HoldyCore = class {
57
58
  this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
58
59
  this.flushAt = Math.max(1, opts.flushAt ?? DEFAULT_FLUSH_AT);
59
60
  this.debug = opts.debug ?? false;
61
+ this.release = typeof opts.release === "string" && opts.release.trim() ? opts.release.trim().slice(0, 64) : null;
60
62
  const intervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
61
63
  if (intervalMs > 0) {
62
64
  this.timer = setInterval(() => void this.flush(), intervalMs);
@@ -73,6 +75,7 @@ var HoldyCore = class {
73
75
  const occurredAt = overrides?.occurredAt ? new Date(overrides.occurredAt).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
74
76
  const context = {
75
77
  ...this.environmentContext(),
78
+ ...this.release ? { release: this.release } : {},
76
79
  ...overrides?.context ?? {}
77
80
  };
78
81
  if (accountKey) context.account_external_id = accountKey;
@@ -374,11 +377,14 @@ var NavLoopDetector = class {
374
377
  return null;
375
378
  }
376
379
  };
380
+ function tagOf(el) {
381
+ return typeof el?.tagName === "string" ? el.tagName.toLowerCase() : "";
382
+ }
377
383
  function resolveInteractive(el) {
378
384
  if (!el) return null;
379
385
  const viaClosest = el.closest?.(INTERACTIVE_SELECTOR) ?? null;
380
386
  if (viaClosest) return viaClosest;
381
- const tag = el.tagName.toLowerCase();
387
+ const tag = tagOf(el);
382
388
  if (INTERACTIVE_TAGS.has(tag)) return el;
383
389
  const role = el.getAttribute("role");
384
390
  if (role === "button" || role === "link" || role === "menuitem" || role === "tab") return el;
@@ -390,7 +396,7 @@ function isDeadClickTarget(el) {
390
396
  if (target.disabled === true) return true;
391
397
  if (target.getAttribute("disabled") !== null) return true;
392
398
  if (target.getAttribute("aria-disabled") === "true") return true;
393
- const tag = target.tagName.toLowerCase();
399
+ const tag = tagOf(target);
394
400
  if (tag === "a") {
395
401
  const href = target.getAttribute("href");
396
402
  if (!href || href === "#" || href.startsWith("javascript:")) return true;
@@ -400,7 +406,7 @@ function isDeadClickTarget(el) {
400
406
  function describeElement(el) {
401
407
  const target = resolveInteractive(el) ?? el;
402
408
  if (!target) return {};
403
- const out = { element: target.tagName.toLowerCase() };
409
+ const out = { element: tagOf(target) };
404
410
  if (target.id) out.element_id = target.id;
405
411
  const role = target.getAttribute("role");
406
412
  if (role) out.element_role = role;
@@ -428,6 +434,22 @@ function errorMessage(reason) {
428
434
  return String(reason);
429
435
  }
430
436
  }
437
+ var STACK_MAX_FRAMES = 8;
438
+ var STACK_MAX_FRAME_CHARS = 200;
439
+ var STACK_FRAME = /^at\s|^\S*@\S+:\d+(?::\d+)?$/;
440
+ function sanitizeStack(stack) {
441
+ if (typeof stack !== "string" || !stack) return null;
442
+ const frames = stack.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && STACK_FRAME.test(line)).slice(0, STACK_MAX_FRAMES).map(
443
+ (line) => line.replace(/(https?:\/\/[^\s)?#]+)[?#][^\s)]*?(?=(?::\d+){0,2}\)?$)/, "$1").slice(0, STACK_MAX_FRAME_CHARS)
444
+ );
445
+ return frames.length > 0 ? frames.join("\n") : null;
446
+ }
447
+ function withStack(stack) {
448
+ return stack ? { stack } : {};
449
+ }
450
+ function stackOf(reason) {
451
+ return typeof reason === "object" && reason !== null ? sanitizeStack(reason.stack) : null;
452
+ }
431
453
  function isAbortError(err) {
432
454
  return typeof err === "object" && err !== null && err.name === "AbortError";
433
455
  }
@@ -505,13 +527,19 @@ function installFrictionCapture(opts) {
505
527
  emit("js_error", {
506
528
  message: String(ev.message ?? "").slice(0, 200),
507
529
  ...ev.filename ? { source: stripRequestUrl(ev.filename, location.href) } : {},
508
- ...ev.lineno ? { line: ev.lineno } : {}
530
+ ...ev.lineno ? { line: ev.lineno } : {},
531
+ ...ev.colno ? { column: ev.colno } : {},
532
+ ...withStack(stackOf(ev.error))
509
533
  });
510
534
  };
511
535
  window.addEventListener("error", onError);
512
536
  cleanups.push(() => window.removeEventListener("error", onError));
513
537
  const onRejection = (ev) => {
514
- emit("js_error", { message: errorMessage(ev.reason).slice(0, 200), unhandled_rejection: true });
538
+ emit("js_error", {
539
+ message: errorMessage(ev.reason).slice(0, 200),
540
+ unhandled_rejection: true,
541
+ ...withStack(stackOf(ev.reason))
542
+ });
515
543
  };
516
544
  window.addEventListener("unhandledrejection", onRejection);
517
545
  cleanups.push(() => window.removeEventListener("unhandledrejection", onRejection));
@@ -593,11 +621,11 @@ function installFrictionCapture(opts) {
593
621
  }
594
622
  const onFocusIn = (ev) => {
595
623
  const el = ev.target;
596
- if (!el || !FORM_CONTROL_TAGS.has(el.tagName.toLowerCase())) return;
624
+ if (!el || !FORM_CONTROL_TAGS.has(tagOf(el))) return;
597
625
  const form = el.form;
598
626
  if (!form) return;
599
627
  const entry = touched.get(form) ?? { form: formKey(form), fields: /* @__PURE__ */ new Set() };
600
- const field = el.name || el.id || el.tagName.toLowerCase();
628
+ const field = el.name || el.id || tagOf(el);
601
629
  entry.fields.add(field);
602
630
  touched.set(form, entry);
603
631
  };
@@ -630,6 +658,86 @@ function installFrictionCapture(opts) {
630
658
  };
631
659
  }
632
660
 
661
+ // src/theme.ts
662
+ var THEME_BUTTON_SELECTOR = 'button[type="submit"],[data-testid*="primary"],.btn-primary,button,[role="button"],a.button';
663
+ var THEME_MAX_BUTTONS = 40;
664
+ var THEME_KEYS = ["font", "size", "fg", "bg", "accent", "accentFg", "radius"];
665
+ var COLOR = /^(rgba?\([\d.,\s%/]{1,40}\)|#[0-9a-fA-F]{3,8}|(?:color|oklch|oklab|hsla?|lab|lch)\([^()]{1,60}\))$/;
666
+ var MIN_ACCENT_CONTRAST = 60;
667
+ var RGB = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/;
668
+ function colorDistance(a, b) {
669
+ const x = RGB.exec(a);
670
+ const y = b ? RGB.exec(b) : null;
671
+ if (!x) return MIN_ACCENT_CONTRAST + 1;
672
+ if (!y) return MIN_ACCENT_CONTRAST + 1;
673
+ return Math.abs(+x[1] - +y[1]) + Math.abs(+x[2] - +y[2]) + Math.abs(+x[3] - +y[3]);
674
+ }
675
+ 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*\))$/;
676
+ function themeVarName(key) {
677
+ return `--holdy-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
678
+ }
679
+ function cleanColor(value) {
680
+ const v = (value ?? "").trim();
681
+ return v && v.length <= 80 && !TRANSPARENT.test(v) && COLOR.test(v) ? v : void 0;
682
+ }
683
+ function cleanFont(value) {
684
+ const first = (value ?? "").split(",")[0].trim().replace(/^["']|["']$/g, "").trim();
685
+ return /^[\w\s-]{1,60}$/.test(first) ? first : void 0;
686
+ }
687
+ function cleanPx(value, min, max) {
688
+ const m = /^(\d+(?:\.\d+)?)px/.exec((value ?? "").trim());
689
+ const n = m ? Math.round(Number(m[1])) : NaN;
690
+ return n >= min && n <= max ? `${n}px` : void 0;
691
+ }
692
+ function visible(el) {
693
+ const r = el.getBoundingClientRect?.();
694
+ return !r || r.width > 0 && r.height > 0;
695
+ }
696
+ function readTheme(doc, getStyle, anchor = null) {
697
+ const body = doc.body;
698
+ if (!body) return null;
699
+ const bodyStyle = getStyle(body);
700
+ const theme = {};
701
+ const put = (key, value) => {
702
+ if (value) theme[key] = value;
703
+ };
704
+ put("font", cleanFont(bodyStyle.getPropertyValue("font-family")));
705
+ put("size", cleanPx(bodyStyle.getPropertyValue("font-size"), 10, 20));
706
+ put("fg", cleanColor(bodyStyle.getPropertyValue("color")));
707
+ put("bg", cleanColor(bodyStyle.getPropertyValue("background-color")) ?? cleanColor(getStyle(doc.documentElement).getPropertyValue("background-color")));
708
+ const candidates = doc.querySelectorAll(THEME_BUTTON_SELECTOR);
709
+ let button = null;
710
+ let best = MIN_ACCENT_CONTRAST;
711
+ for (let i = 0; i < candidates.length && i < THEME_MAX_BUTTONS; i++) {
712
+ const el = candidates[i];
713
+ if (!visible(el)) continue;
714
+ const s = getStyle(el);
715
+ const accent = cleanColor(s.getPropertyValue("background-color"));
716
+ if (!accent || accent === theme.bg) continue;
717
+ const contrast = colorDistance(accent, theme.bg);
718
+ if (contrast <= best) continue;
719
+ best = contrast;
720
+ put("accent", accent);
721
+ put("accentFg", cleanColor(s.getPropertyValue("color")));
722
+ button = el;
723
+ }
724
+ const radiusSource = anchor && anchor.tagName.toLowerCase() === "button" ? anchor : button;
725
+ if (radiusSource) put("radius", cleanPx(getStyle(radiusSource).getPropertyValue("border-top-left-radius"), 0, 24));
726
+ return Object.keys(theme).length > 0 ? theme : null;
727
+ }
728
+ function themeVars(theme) {
729
+ const out = [];
730
+ for (const key of THEME_KEYS) {
731
+ const value = theme?.[key];
732
+ if (value) out.push([themeVarName(key), value]);
733
+ }
734
+ return out;
735
+ }
736
+ function applyThemeVars(el, theme) {
737
+ for (const key of THEME_KEYS) el.style.removeProperty(themeVarName(key));
738
+ for (const [name, value] of themeVars(theme)) el.style.setProperty(name, value);
739
+ }
740
+
633
741
  // src/skeleton.ts
634
742
  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
743
  var SKELETON_MAX_ELEMENTS = 80;
@@ -676,18 +784,18 @@ function describeSkeletonElement(el, viewport) {
676
784
  if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport);
677
785
  return out;
678
786
  }
679
- function collectSkeleton(root, path, viewport) {
787
+ function collectSkeleton(root, path, viewport, theme = null) {
680
788
  const found = root.querySelectorAll(SKELETON_SELECTOR);
681
- const visible = [];
789
+ const visible2 = [];
682
790
  for (let i = 0; i < found.length; i++) {
683
791
  const el = found[i];
684
792
  if (el.tagName.toLowerCase() === "input" && (el.getAttribute("type") ?? "").toLowerCase() === "hidden") continue;
685
793
  if (!isVisible(el)) continue;
686
- visible.push(el);
794
+ visible2.push(el);
687
795
  }
688
- const elements = visible.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
796
+ const elements = visible2.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
689
797
  const structural = elements.map(({ b: _b, ...rest }) => rest);
690
- return { path, hash: fnv1a(JSON.stringify(structural)), count: visible.length, elements };
798
+ return { path, hash: fnv1a(JSON.stringify(structural)), count: visible2.length, elements, ...theme ? { theme } : {} };
691
799
  }
692
800
  function loadSent() {
693
801
  try {
@@ -721,7 +829,8 @@ function installSkeletonCapture(opts) {
721
829
  height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
722
830
  scrollY: window.scrollY
723
831
  };
724
- const skeleton = collectSkeleton(document, path, viewport);
832
+ const theme = readTheme(document, (el) => window.getComputedStyle(el));
833
+ const skeleton = collectSkeleton(document, path, viewport, theme);
725
834
  const key = `${path}:${skeleton.hash}`;
726
835
  if (sent.has(key)) return;
727
836
  sent.add(key);
@@ -807,6 +916,12 @@ function randomId() {
807
916
  return `hol-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
808
917
  }
809
918
  }
919
+ function clearSessionId() {
920
+ try {
921
+ sessionStorage.removeItem(SESSION_STORAGE_KEY);
922
+ } catch {
923
+ }
924
+ }
810
925
  function resolveSessionId() {
811
926
  const existing = readStorage("session", SESSION_STORAGE_KEY);
812
927
  if (existing) return existing;
@@ -860,6 +975,33 @@ function buildWebContext() {
860
975
  };
861
976
  }
862
977
 
978
+ // src/tip-css.ts
979
+ var TIP_CSS = `:host{all:initial;--h-bg:#111;--h-fg:#fff;--h-accent:#fff;--h-accent-fg:#111;--h-border:transparent}
980
+ @media (prefers-color-scheme:dark){:host{--h-bg:#fff;--h-fg:#111;--h-accent:#111;--h-accent-fg:#fff}}
981
+ .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}
982
+ .tip p{margin:0;flex:1}
983
+ .tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1;color:inherit}
984
+ .tip button:hover,.tip button:focus-visible{opacity:1}
985
+ .ask{flex-direction:column;width:300px;max-width:calc(100vw - 16px);gap:8px}
986
+ .ask .head{display:flex;gap:10px;align-items:flex-start;width:100%}
987
+ .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}
988
+ .ask textarea:focus-visible{outline:2px solid color-mix(in srgb,var(--holdy-fg,var(--h-fg)) 45%,transparent)}
989
+ .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}
990
+ .ask .send[disabled]{opacity:.4;cursor:default}
991
+ .list{flex-direction:column;width:300px;max-width:calc(100vw - 16px);gap:8px}
992
+ .list .head{display:flex;gap:10px;align-items:flex-start;width:100%}
993
+ .list .head p{display:flex;justify-content:space-between;gap:10px;align-items:baseline}
994
+ .list .count{opacity:.7;font-size:12px;white-space:nowrap}
995
+ .list ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px;width:100%}
996
+ .list li{display:flex;gap:8px;align-items:flex-start}
997
+ .list li.done{opacity:.55;text-decoration:line-through}
998
+ .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}
999
+ .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))}
1000
+ .list.folded ul{display:none}
1001
+ .list.folded{width:auto}
1002
+ .preview{flex:none;order:-1;width:100%;font-size:11px;opacity:.7}
1003
+ .tip:has(.preview){flex-wrap:wrap;row-gap:4px}`;
1004
+
863
1005
  // src/arm.ts
864
1006
  function fnv1a2(input) {
865
1007
  let hash = 2166136261;
@@ -889,12 +1031,16 @@ var SCAN_DEBOUNCE_MS = 250;
889
1031
  var SEEN_KEY = "holdy_changes_seen";
890
1032
  var DISMISSED_KEY = "holdy_changes_dismissed";
891
1033
  var TIP_GAP_PX = 8;
892
- var TIP_CSS = `:host{all:initial}
893
- .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}
894
- .tip p{margin:0;flex:1}
895
- .tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1}
896
- .tip button:hover,.tip button:focus-visible{opacity:1}
897
- @media (prefers-color-scheme:dark){.tip{background:#fff;color:#111}}`;
1034
+ var ANSWERED_KEY = "holdy_questions_answered";
1035
+ var FOLDED_KEY = "holdy_checklist_folded";
1036
+ var COMPLETED_KEY = "holdy_checklist_completed";
1037
+ var PROGRESS_REFRESH_MS = 60 * 1e3;
1038
+ var VOICE_EVENT = "$voice";
1039
+ var MAX_ANSWER_CHARS = 1e3;
1040
+ var THANKS_MS = 1600;
1041
+ var PREVIEW_PARAM = "holdy_preview";
1042
+ var PREVIEW_KEY = "holdy_preview";
1043
+ var PREVIEW_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
898
1044
  function normalizeText(text) {
899
1045
  return text.replace(/\s+/g, " ").trim();
900
1046
  }
@@ -907,13 +1053,42 @@ function isAnchor(value) {
907
1053
  if (a?.kind === "page") return true;
908
1054
  return (a?.kind === "selector" || a?.kind === "text") && typeof a.value === "string" && a.value.length > 0;
909
1055
  }
1056
+ function isItem(value) {
1057
+ const i = value;
1058
+ return typeof i?.definition_name === "string" && typeof i.label === "string" && Array.isArray(i.events) && i.events.every((e) => typeof e === "string");
1059
+ }
910
1060
  function isChange(value) {
911
1061
  const c = value;
912
- 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";
1062
+ 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";
913
1063
  }
914
- function changesForPage(changes, path, accountKey) {
915
- if (!accountKey) return [];
916
- return changes.filter((c) => matchesPathTemplate(c.path, path)).map((change) => ({ change, arm: changeArm(change.id, accountKey, change.holdout_pct) }));
1064
+ function changesForPage(changes, path, accountKey, previewId = null) {
1065
+ return changes.filter((c) => c.kind === "checklist" || matchesPathTemplate(c.path, path)).flatMap((change) => {
1066
+ if (change.id === previewId) return [{ change, arm: "change", preview: true }];
1067
+ if (!accountKey) return [];
1068
+ return [{ change, arm: changeArm(change.id, accountKey, change.holdout_pct), preview: false }];
1069
+ });
1070
+ }
1071
+ function resolvePreviewId(search, store) {
1072
+ let asked = null;
1073
+ try {
1074
+ asked = new URLSearchParams(search).get(PREVIEW_PARAM);
1075
+ } catch {
1076
+ asked = null;
1077
+ }
1078
+ try {
1079
+ if (asked !== null) {
1080
+ if (PREVIEW_ID.test(asked)) {
1081
+ store?.setItem(PREVIEW_KEY, asked);
1082
+ return asked;
1083
+ }
1084
+ store?.removeItem(PREVIEW_KEY);
1085
+ return null;
1086
+ }
1087
+ const kept = store?.getItem(PREVIEW_KEY) ?? null;
1088
+ return kept && PREVIEW_ID.test(kept) ? kept : null;
1089
+ } catch {
1090
+ return asked !== null && PREVIEW_ID.test(asked) ? asked : null;
1091
+ }
917
1092
  }
918
1093
  function storage(kind) {
919
1094
  try {
@@ -975,7 +1150,7 @@ function findTextNode(el, match) {
975
1150
  }
976
1151
  function installProductChanges(opts) {
977
1152
  if (typeof window === "undefined" || typeof document === "undefined") {
978
- return { onNavigate: () => void 0, refresh: async () => void 0, teardown: () => void 0 };
1153
+ return { onNavigate: () => void 0, onEvent: () => void 0, refresh: async () => void 0, teardown: () => void 0 };
979
1154
  }
980
1155
  let changes = [];
981
1156
  let lastFetch = 0;
@@ -984,6 +1159,11 @@ function installProductChanges(opts) {
984
1159
  let host = null;
985
1160
  const copies = /* @__PURE__ */ new Map();
986
1161
  const tips = /* @__PURE__ */ new Map();
1162
+ const progress = /* @__PURE__ */ new Map();
1163
+ const progressAt = /* @__PURE__ */ new Map();
1164
+ const lists = /* @__PURE__ */ new Map();
1165
+ let fetchedForKey = null;
1166
+ let previewId = resolvePreviewId(location.search, storage("session"));
987
1167
  const log = (message) => {
988
1168
  if (opts.debug) console.warn(`[holdy] ${message}`);
989
1169
  };
@@ -1033,21 +1213,66 @@ function installProductChanges(opts) {
1033
1213
  document.body.appendChild(host);
1034
1214
  return root;
1035
1215
  };
1036
- const showTip = (anchor, change) => {
1216
+ let dismissedPreview = false;
1217
+ const previewTag = () => {
1218
+ const tag = document.createElement("span");
1219
+ tag.className = "preview";
1220
+ tag.textContent = "Pr\xE9via, s\xF3 voc\xEA v\xEA";
1221
+ return tag;
1222
+ };
1223
+ const showTip = (anchor, change, preview) => {
1037
1224
  if (tips.has(change.id)) return true;
1038
- if (readSet(storage("local"), DISMISSED_KEY).has(change.id)) return true;
1225
+ if (!preview && readSet(storage("local"), DISMISSED_KEY).has(change.id)) return true;
1226
+ const asking = change.kind === "question";
1227
+ if (!preview && asking && readSet(storage("local"), ANSWERED_KEY).has(change.id)) return true;
1039
1228
  const shadow = ensureRoot();
1040
1229
  if (!shadow) return false;
1230
+ if (host) applyThemeVars(host, readTheme(document, (el) => window.getComputedStyle(el), anchor));
1041
1231
  const card = document.createElement("div");
1042
- card.className = "tip";
1043
- card.setAttribute("role", "tooltip");
1232
+ card.className = asking ? "tip ask" : "tip";
1233
+ card.setAttribute("role", asking ? "dialog" : "tooltip");
1044
1234
  const text = document.createElement("p");
1045
1235
  text.textContent = change.content.text;
1046
1236
  const close = document.createElement("button");
1047
1237
  close.type = "button";
1048
1238
  close.setAttribute("aria-label", "Fechar");
1049
1239
  close.textContent = "\xD7";
1050
- card.append(text, close);
1240
+ if (asking) {
1241
+ const head = document.createElement("div");
1242
+ head.className = "head";
1243
+ head.append(text, close);
1244
+ const input = document.createElement("textarea");
1245
+ input.maxLength = MAX_ANSWER_CHARS;
1246
+ input.setAttribute("aria-label", change.content.text);
1247
+ const send = document.createElement("button");
1248
+ send.type = "button";
1249
+ send.className = "send";
1250
+ send.textContent = "Enviar";
1251
+ send.disabled = true;
1252
+ input.addEventListener("input", () => {
1253
+ send.disabled = input.value.trim().length === 0;
1254
+ });
1255
+ send.addEventListener("click", () => {
1256
+ const answer = input.value.trim().slice(0, MAX_ANSWER_CHARS);
1257
+ if (!answer) return;
1258
+ if (!preview) {
1259
+ addToSet(storage("local"), ANSWERED_KEY, change.id);
1260
+ opts.track(VOICE_EVENT, {
1261
+ question: change.content.text,
1262
+ text: answer,
1263
+ action_plan_id: change.plan_id,
1264
+ change_id: change.id,
1265
+ path: location.pathname
1266
+ });
1267
+ }
1268
+ card.replaceChildren(Object.assign(document.createElement("p"), { textContent: "Obrigado pela resposta." }));
1269
+ setTimeout(remove, THANKS_MS);
1270
+ });
1271
+ card.append(head, input, send);
1272
+ } else {
1273
+ card.append(text, close);
1274
+ }
1275
+ if (preview) card.prepend(previewTag());
1051
1276
  card.style.top = "-9999px";
1052
1277
  card.style.left = "-9999px";
1053
1278
  shadow.appendChild(card);
@@ -1077,6 +1302,11 @@ function installProductChanges(opts) {
1077
1302
  card.style.left = `${Math.max(8, left)}px`;
1078
1303
  }
1079
1304
  close.addEventListener("click", () => {
1305
+ if (preview) {
1306
+ dismissedPreview = true;
1307
+ remove();
1308
+ return;
1309
+ }
1080
1310
  addToSet(storage("local"), DISMISSED_KEY, change.id);
1081
1311
  opts.track("$change_dismissed", { change_id: change.id, kind: change.kind });
1082
1312
  remove();
@@ -1087,20 +1317,143 @@ function installProductChanges(opts) {
1087
1317
  window.addEventListener("resize", position);
1088
1318
  return true;
1089
1319
  };
1320
+ const doneOf = (change) => progress.get(change.id) ?? /* @__PURE__ */ new Set();
1321
+ const allDone = (change) => {
1322
+ const done = doneOf(change);
1323
+ return (change.content.items ?? []).every((i) => done.has(i.definition_name));
1324
+ };
1325
+ const fetchProgress = async (change) => {
1326
+ const account = opts.getAccountKey();
1327
+ if (!account) return;
1328
+ const last = progressAt.get(change.id) ?? 0;
1329
+ if (Date.now() - last < PROGRESS_REFRESH_MS) return;
1330
+ progressAt.set(change.id, Date.now());
1331
+ try {
1332
+ const res = await fetch(
1333
+ `${opts.baseUrl}/runtime/checklist?change_id=${encodeURIComponent(change.id)}&account=${encodeURIComponent(account)}`,
1334
+ { headers: { Authorization: `Bearer ${opts.apiKey}` } }
1335
+ );
1336
+ if (!res.ok) return;
1337
+ const body = await res.json();
1338
+ const done = Array.isArray(body.done) ? body.done.filter((d) => typeof d === "string") : [];
1339
+ const set = doneOf(change);
1340
+ for (const d of done) set.add(d);
1341
+ progress.set(change.id, set);
1342
+ lists.get(change.id)?.();
1343
+ } catch (err) {
1344
+ log(`checklist progress error: ${err.message}`);
1345
+ }
1346
+ };
1347
+ const showChecklist = (change, preview) => {
1348
+ if (lists.has(change.id)) return true;
1349
+ if (!preview && readSet(storage("local"), COMPLETED_KEY).has(change.id)) return true;
1350
+ const shadow = ensureRoot();
1351
+ if (!shadow) return false;
1352
+ const items = change.content.items ?? [];
1353
+ const card = document.createElement("div");
1354
+ card.className = "tip list";
1355
+ card.setAttribute("role", "region");
1356
+ card.setAttribute("aria-label", change.content.text);
1357
+ const head = document.createElement("div");
1358
+ head.className = "head";
1359
+ const title = document.createElement("p");
1360
+ const strong = document.createElement("strong");
1361
+ strong.textContent = change.content.text;
1362
+ const count = document.createElement("span");
1363
+ count.className = "count";
1364
+ title.append(strong, count);
1365
+ const fold = document.createElement("button");
1366
+ fold.type = "button";
1367
+ head.append(title, fold);
1368
+ const list = document.createElement("ul");
1369
+ card.append(head, list);
1370
+ if (preview) card.prepend(previewTag());
1371
+ card.style.position = "fixed";
1372
+ card.style.right = "16px";
1373
+ card.style.bottom = "16px";
1374
+ shadow.appendChild(card);
1375
+ const remove = () => {
1376
+ card.remove();
1377
+ lists.delete(change.id);
1378
+ };
1379
+ const render = () => {
1380
+ const done = doneOf(change);
1381
+ const n = items.filter((i) => done.has(i.definition_name)).length;
1382
+ count.textContent = `${n} de ${items.length}`;
1383
+ const folded = readSet(storage("local"), FOLDED_KEY).has(change.id);
1384
+ card.classList.toggle("folded", folded);
1385
+ fold.textContent = folded ? "+" : "\u2013";
1386
+ fold.setAttribute("aria-label", folded ? "Abrir" : "Recolher");
1387
+ list.replaceChildren(
1388
+ ...items.map((item) => {
1389
+ const li = document.createElement("li");
1390
+ const isDone = done.has(item.definition_name);
1391
+ if (isDone) li.className = "done";
1392
+ const mark = document.createElement("span");
1393
+ mark.className = "mark";
1394
+ mark.setAttribute("aria-hidden", "true");
1395
+ mark.textContent = isDone ? "\u2713" : "";
1396
+ const label = document.createElement("span");
1397
+ label.textContent = item.label;
1398
+ li.append(mark, label);
1399
+ return li;
1400
+ })
1401
+ );
1402
+ if (!preview && n === items.length && items.length > 0) {
1403
+ addToSet(storage("local"), COMPLETED_KEY, change.id);
1404
+ opts.track("$checklist_completed", { change_id: change.id });
1405
+ remove();
1406
+ }
1407
+ };
1408
+ fold.addEventListener("click", () => {
1409
+ const store = storage("local");
1410
+ const set = readSet(store, FOLDED_KEY);
1411
+ if (set.has(change.id)) set.delete(change.id);
1412
+ else set.add(change.id);
1413
+ try {
1414
+ store?.setItem(FOLDED_KEY, JSON.stringify([...set].slice(-200)));
1415
+ } catch {
1416
+ }
1417
+ render();
1418
+ });
1419
+ lists.set(change.id, render);
1420
+ render();
1421
+ void fetchProgress(change);
1422
+ return true;
1423
+ };
1090
1424
  const scan = () => {
1091
1425
  timer = null;
1092
- const active = changesForPage(changes, location.pathname, opts.getAccountKey());
1426
+ const active = changesForPage(changes, location.pathname, opts.getAccountKey(), previewId);
1093
1427
  const activeIds = new Set(active.map((a) => a.change.id));
1094
1428
  for (const id of [...copies.keys()]) if (!activeIds.has(id)) restoreCopy(id);
1095
1429
  for (const [id, remove] of [...tips]) if (!activeIds.has(id)) remove();
1096
- for (const { change, arm } of active) {
1430
+ for (const [id, render] of [...lists]) {
1431
+ if (!activeIds.has(id)) {
1432
+ lists.delete(id);
1433
+ render();
1434
+ }
1435
+ }
1436
+ for (const { change, arm, preview } of active) {
1097
1437
  const el = findAnchor(change.anchor);
1098
- if (!el && change.anchor.kind !== "page") continue;
1099
- if (change.kind === "copy") {
1438
+ if (!el && change.anchor.kind !== "page") {
1439
+ if (preview) log(`preview: \xE2ncora n\xE3o encontrada em ${location.pathname}`);
1440
+ continue;
1441
+ }
1442
+ if (preview) {
1443
+ if (change.kind === "checklist") showChecklist(change, true);
1444
+ else if (change.kind === "copy") {
1445
+ if (el) applyCopy(el, change);
1446
+ } else if (!dismissedPreview) showTip(el, change, true);
1447
+ continue;
1448
+ }
1449
+ if (change.kind === "checklist") {
1450
+ if (arm === "holdout" || allDone(change) || showChecklist(change, false)) expose(change, arm);
1451
+ if (arm === "change") void fetchProgress(change);
1452
+ } else if (change.kind === "copy") {
1100
1453
  if (!el) continue;
1101
1454
  const shown = arm === "change" ? applyCopy(el, change) : eligibleCopyNode(el, change) !== null;
1102
1455
  if (shown) expose(change, arm);
1103
- } else if (arm === "holdout" || showTip(el, change)) {
1456
+ } else if (arm === "holdout" || showTip(el, change, false)) {
1104
1457
  expose(change, arm);
1105
1458
  }
1106
1459
  }
@@ -1110,8 +1463,14 @@ function installProductChanges(opts) {
1110
1463
  };
1111
1464
  const refresh = async () => {
1112
1465
  lastFetch = Date.now();
1466
+ const account = opts.getAccountKey();
1467
+ fetchedForKey = account;
1468
+ const params = new URLSearchParams();
1469
+ if (account) params.set("account", account);
1470
+ if (previewId) params.set("preview", previewId);
1471
+ const qs = params.toString();
1113
1472
  try {
1114
- const res = await fetch(`${opts.baseUrl}/runtime/changes`, {
1473
+ const res = await fetch(`${opts.baseUrl}/runtime/changes${qs ? `?${qs}` : ""}`, {
1115
1474
  headers: { Authorization: `Bearer ${opts.apiKey}` }
1116
1475
  });
1117
1476
  if (!res.ok) {
@@ -1136,9 +1495,28 @@ function installProductChanges(opts) {
1136
1495
  void refresh();
1137
1496
  return {
1138
1497
  onNavigate: () => {
1139
- if (Date.now() - lastFetch > NAV_REFRESH_MS) void refresh();
1498
+ const asked = resolvePreviewId(location.search, storage("session"));
1499
+ const previewChanged = asked !== previewId;
1500
+ if (previewChanged) {
1501
+ previewId = asked;
1502
+ dismissedPreview = false;
1503
+ for (const id of [...copies.keys()]) restoreCopy(id);
1504
+ for (const remove of [...tips.values()]) remove();
1505
+ }
1506
+ if (previewChanged || Date.now() - lastFetch > NAV_REFRESH_MS || opts.getAccountKey() !== fetchedForKey) void refresh();
1140
1507
  schedule();
1141
1508
  },
1509
+ onEvent: (name) => {
1510
+ for (const change of changes) {
1511
+ if (change.kind !== "checklist") continue;
1512
+ const hit = (change.content.items ?? []).filter((i) => i.events.includes(name));
1513
+ if (hit.length === 0) continue;
1514
+ const set = doneOf(change);
1515
+ for (const i of hit) set.add(i.definition_name);
1516
+ progress.set(change.id, set);
1517
+ lists.get(change.id)?.();
1518
+ }
1519
+ },
1142
1520
  refresh,
1143
1521
  teardown: () => {
1144
1522
  clearInterval(interval);
@@ -1146,6 +1524,7 @@ function installProductChanges(opts) {
1146
1524
  observer?.disconnect();
1147
1525
  for (const id of [...copies.keys()]) restoreCopy(id);
1148
1526
  for (const remove of [...tips.values()]) remove();
1527
+ for (const id of [...lists.keys()]) lists.delete(id);
1149
1528
  host?.remove();
1150
1529
  host = null;
1151
1530
  root = null;
@@ -1222,6 +1601,10 @@ var HoldyWeb = class extends HoldyCore {
1222
1601
  super.group(accountKey, traits);
1223
1602
  this.changes?.onNavigate();
1224
1603
  }
1604
+ track(name, properties, overrides) {
1605
+ super.track(name, properties, overrides);
1606
+ if (!name.startsWith("$")) this.changes?.onEvent(name);
1607
+ }
1225
1608
  reset() {
1226
1609
  this.userId = null;
1227
1610
  this.accountKey = null;
@@ -1229,6 +1612,7 @@ var HoldyWeb = class extends HoldyCore {
1229
1612
  localStorage.removeItem(ANON_STORAGE_KEY);
1230
1613
  } catch {
1231
1614
  }
1615
+ clearSessionId();
1232
1616
  this.anonymousId = resolveAnonymousId();
1233
1617
  this.changes?.onNavigate();
1234
1618
  }