@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.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,86 @@ 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
+
603
711
  // src/skeleton.ts
604
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]';
605
713
  var SKELETON_MAX_ELEMENTS = 80;
@@ -646,18 +754,18 @@ function describeSkeletonElement(el, viewport) {
646
754
  if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport);
647
755
  return out;
648
756
  }
649
- function collectSkeleton(root, path, viewport) {
757
+ function collectSkeleton(root, path, viewport, theme = null) {
650
758
  const found = root.querySelectorAll(SKELETON_SELECTOR);
651
- const visible = [];
759
+ const visible2 = [];
652
760
  for (let i = 0; i < found.length; i++) {
653
761
  const el = found[i];
654
762
  if (el.tagName.toLowerCase() === "input" && (el.getAttribute("type") ?? "").toLowerCase() === "hidden") continue;
655
763
  if (!isVisible(el)) continue;
656
- visible.push(el);
764
+ visible2.push(el);
657
765
  }
658
- const elements = visible.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
766
+ const elements = visible2.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
659
767
  const structural = elements.map(({ b: _b, ...rest }) => rest);
660
- return { path, hash: fnv1a(JSON.stringify(structural)), count: visible.length, elements };
768
+ return { path, hash: fnv1a(JSON.stringify(structural)), count: visible2.length, elements, ...theme ? { theme } : {} };
661
769
  }
662
770
  function loadSent() {
663
771
  try {
@@ -691,7 +799,8 @@ function installSkeletonCapture(opts) {
691
799
  height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
692
800
  scrollY: window.scrollY
693
801
  };
694
- const skeleton = collectSkeleton(document, path, viewport);
802
+ const theme = readTheme(document, (el) => window.getComputedStyle(el));
803
+ const skeleton = collectSkeleton(document, path, viewport, theme);
695
804
  const key = `${path}:${skeleton.hash}`;
696
805
  if (sent.has(key)) return;
697
806
  sent.add(key);
@@ -777,6 +886,12 @@ function randomId() {
777
886
  return `hol-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
778
887
  }
779
888
  }
889
+ function clearSessionId() {
890
+ try {
891
+ sessionStorage.removeItem(SESSION_STORAGE_KEY);
892
+ } catch {
893
+ }
894
+ }
780
895
  function resolveSessionId() {
781
896
  const existing = readStorage("session", SESSION_STORAGE_KEY);
782
897
  if (existing) return existing;
@@ -830,6 +945,33 @@ function buildWebContext() {
830
945
  };
831
946
  }
832
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
+
833
975
  // src/arm.ts
834
976
  function fnv1a2(input) {
835
977
  let hash = 2166136261;
@@ -859,12 +1001,16 @@ var SCAN_DEBOUNCE_MS = 250;
859
1001
  var SEEN_KEY = "holdy_changes_seen";
860
1002
  var DISMISSED_KEY = "holdy_changes_dismissed";
861
1003
  var TIP_GAP_PX = 8;
862
- var TIP_CSS = `:host{all:initial}
863
- .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}
864
- .tip p{margin:0;flex:1}
865
- .tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1}
866
- .tip button:hover,.tip button:focus-visible{opacity:1}
867
- @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;
868
1014
  function normalizeText(text) {
869
1015
  return text.replace(/\s+/g, " ").trim();
870
1016
  }
@@ -877,13 +1023,42 @@ function isAnchor(value) {
877
1023
  if (a?.kind === "page") return true;
878
1024
  return (a?.kind === "selector" || a?.kind === "text") && typeof a.value === "string" && a.value.length > 0;
879
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
+ }
880
1030
  function isChange(value) {
881
1031
  const c = value;
882
- 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";
883
1033
  }
884
- function changesForPage(changes, path, accountKey) {
885
- if (!accountKey) return [];
886
- 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
+ }
887
1062
  }
888
1063
  function storage(kind) {
889
1064
  try {
@@ -945,7 +1120,7 @@ function findTextNode(el, match) {
945
1120
  }
946
1121
  function installProductChanges(opts) {
947
1122
  if (typeof window === "undefined" || typeof document === "undefined") {
948
- 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 };
949
1124
  }
950
1125
  let changes = [];
951
1126
  let lastFetch = 0;
@@ -954,6 +1129,11 @@ function installProductChanges(opts) {
954
1129
  let host = null;
955
1130
  const copies = /* @__PURE__ */ new Map();
956
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"));
957
1137
  const log = (message) => {
958
1138
  if (opts.debug) console.warn(`[holdy] ${message}`);
959
1139
  };
@@ -1003,21 +1183,66 @@ function installProductChanges(opts) {
1003
1183
  document.body.appendChild(host);
1004
1184
  return root;
1005
1185
  };
1006
- 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) => {
1007
1194
  if (tips.has(change.id)) return true;
1008
- 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;
1009
1198
  const shadow = ensureRoot();
1010
1199
  if (!shadow) return false;
1200
+ if (host) applyThemeVars(host, readTheme(document, (el) => window.getComputedStyle(el), anchor));
1011
1201
  const card = document.createElement("div");
1012
- card.className = "tip";
1013
- card.setAttribute("role", "tooltip");
1202
+ card.className = asking ? "tip ask" : "tip";
1203
+ card.setAttribute("role", asking ? "dialog" : "tooltip");
1014
1204
  const text = document.createElement("p");
1015
1205
  text.textContent = change.content.text;
1016
1206
  const close = document.createElement("button");
1017
1207
  close.type = "button";
1018
1208
  close.setAttribute("aria-label", "Fechar");
1019
1209
  close.textContent = "\xD7";
1020
- 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());
1021
1246
  card.style.top = "-9999px";
1022
1247
  card.style.left = "-9999px";
1023
1248
  shadow.appendChild(card);
@@ -1047,6 +1272,11 @@ function installProductChanges(opts) {
1047
1272
  card.style.left = `${Math.max(8, left)}px`;
1048
1273
  }
1049
1274
  close.addEventListener("click", () => {
1275
+ if (preview) {
1276
+ dismissedPreview = true;
1277
+ remove();
1278
+ return;
1279
+ }
1050
1280
  addToSet(storage("local"), DISMISSED_KEY, change.id);
1051
1281
  opts.track("$change_dismissed", { change_id: change.id, kind: change.kind });
1052
1282
  remove();
@@ -1057,20 +1287,143 @@ function installProductChanges(opts) {
1057
1287
  window.addEventListener("resize", position);
1058
1288
  return true;
1059
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
+ };
1060
1394
  const scan = () => {
1061
1395
  timer = null;
1062
- const active = changesForPage(changes, location.pathname, opts.getAccountKey());
1396
+ const active = changesForPage(changes, location.pathname, opts.getAccountKey(), previewId);
1063
1397
  const activeIds = new Set(active.map((a) => a.change.id));
1064
1398
  for (const id of [...copies.keys()]) if (!activeIds.has(id)) restoreCopy(id);
1065
1399
  for (const [id, remove] of [...tips]) if (!activeIds.has(id)) remove();
1066
- 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) {
1067
1407
  const el = findAnchor(change.anchor);
1068
- if (!el && change.anchor.kind !== "page") continue;
1069
- 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") {
1070
1423
  if (!el) continue;
1071
1424
  const shown = arm === "change" ? applyCopy(el, change) : eligibleCopyNode(el, change) !== null;
1072
1425
  if (shown) expose(change, arm);
1073
- } else if (arm === "holdout" || showTip(el, change)) {
1426
+ } else if (arm === "holdout" || showTip(el, change, false)) {
1074
1427
  expose(change, arm);
1075
1428
  }
1076
1429
  }
@@ -1080,8 +1433,14 @@ function installProductChanges(opts) {
1080
1433
  };
1081
1434
  const refresh = async () => {
1082
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();
1083
1442
  try {
1084
- const res = await fetch(`${opts.baseUrl}/runtime/changes`, {
1443
+ const res = await fetch(`${opts.baseUrl}/runtime/changes${qs ? `?${qs}` : ""}`, {
1085
1444
  headers: { Authorization: `Bearer ${opts.apiKey}` }
1086
1445
  });
1087
1446
  if (!res.ok) {
@@ -1106,9 +1465,28 @@ function installProductChanges(opts) {
1106
1465
  void refresh();
1107
1466
  return {
1108
1467
  onNavigate: () => {
1109
- 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();
1110
1477
  schedule();
1111
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
+ },
1112
1490
  refresh,
1113
1491
  teardown: () => {
1114
1492
  clearInterval(interval);
@@ -1116,6 +1494,7 @@ function installProductChanges(opts) {
1116
1494
  observer?.disconnect();
1117
1495
  for (const id of [...copies.keys()]) restoreCopy(id);
1118
1496
  for (const remove of [...tips.values()]) remove();
1497
+ for (const id of [...lists.keys()]) lists.delete(id);
1119
1498
  host?.remove();
1120
1499
  host = null;
1121
1500
  root = null;
@@ -1192,6 +1571,10 @@ var HoldyWeb = class extends HoldyCore {
1192
1571
  super.group(accountKey, traits);
1193
1572
  this.changes?.onNavigate();
1194
1573
  }
1574
+ track(name, properties, overrides) {
1575
+ super.track(name, properties, overrides);
1576
+ if (!name.startsWith("$")) this.changes?.onEvent(name);
1577
+ }
1195
1578
  reset() {
1196
1579
  this.userId = null;
1197
1580
  this.accountKey = null;
@@ -1199,6 +1582,7 @@ var HoldyWeb = class extends HoldyCore {
1199
1582
  localStorage.removeItem(ANON_STORAGE_KEY);
1200
1583
  } catch {
1201
1584
  }
1585
+ clearSessionId();
1202
1586
  this.anonymousId = resolveAnonymousId();
1203
1587
  this.changes?.onNavigate();
1204
1588
  }