@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/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,86 @@ 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
+
549
657
  // src/skeleton.ts
550
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]';
551
659
  var SKELETON_MAX_ELEMENTS = 80;
@@ -592,18 +700,18 @@ function describeSkeletonElement(el, viewport) {
592
700
  if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport);
593
701
  return out;
594
702
  }
595
- function collectSkeleton(root, path, viewport) {
703
+ function collectSkeleton(root, path, viewport, theme = null) {
596
704
  const found = root.querySelectorAll(SKELETON_SELECTOR);
597
- const visible = [];
705
+ const visible2 = [];
598
706
  for (let i = 0; i < found.length; i++) {
599
707
  const el = found[i];
600
708
  if (el.tagName.toLowerCase() === "input" && (el.getAttribute("type") ?? "").toLowerCase() === "hidden") continue;
601
709
  if (!isVisible(el)) continue;
602
- visible.push(el);
710
+ visible2.push(el);
603
711
  }
604
- const elements = visible.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
712
+ const elements = visible2.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
605
713
  const structural = elements.map(({ b: _b, ...rest }) => rest);
606
- return { path, hash: fnv1a(JSON.stringify(structural)), count: visible.length, elements };
714
+ return { path, hash: fnv1a(JSON.stringify(structural)), count: visible2.length, elements, ...theme ? { theme } : {} };
607
715
  }
608
716
  function loadSent() {
609
717
  try {
@@ -637,7 +745,8 @@ function installSkeletonCapture(opts) {
637
745
  height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
638
746
  scrollY: window.scrollY
639
747
  };
640
- const skeleton = collectSkeleton(document, path, viewport);
748
+ const theme = readTheme(document, (el) => window.getComputedStyle(el));
749
+ const skeleton = collectSkeleton(document, path, viewport, theme);
641
750
  const key = `${path}:${skeleton.hash}`;
642
751
  if (sent.has(key)) return;
643
752
  sent.add(key);
@@ -723,6 +832,12 @@ function randomId() {
723
832
  return `hol-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
724
833
  }
725
834
  }
835
+ function clearSessionId() {
836
+ try {
837
+ sessionStorage.removeItem(SESSION_STORAGE_KEY);
838
+ } catch {
839
+ }
840
+ }
726
841
  function resolveSessionId() {
727
842
  const existing = readStorage("session", SESSION_STORAGE_KEY);
728
843
  if (existing) return existing;
@@ -776,6 +891,33 @@ function buildWebContext() {
776
891
  };
777
892
  }
778
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
+
779
921
  // src/arm.ts
780
922
  function fnv1a2(input) {
781
923
  let hash = 2166136261;
@@ -805,12 +947,16 @@ var SCAN_DEBOUNCE_MS = 250;
805
947
  var SEEN_KEY = "holdy_changes_seen";
806
948
  var DISMISSED_KEY = "holdy_changes_dismissed";
807
949
  var TIP_GAP_PX = 8;
808
- var TIP_CSS = `:host{all:initial}
809
- .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}
810
- .tip p{margin:0;flex:1}
811
- .tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1}
812
- .tip button:hover,.tip button:focus-visible{opacity:1}
813
- @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;
814
960
  function normalizeText(text) {
815
961
  return text.replace(/\s+/g, " ").trim();
816
962
  }
@@ -823,13 +969,42 @@ function isAnchor(value) {
823
969
  if (a?.kind === "page") return true;
824
970
  return (a?.kind === "selector" || a?.kind === "text") && typeof a.value === "string" && a.value.length > 0;
825
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
+ }
826
976
  function isChange(value) {
827
977
  const c = value;
828
- 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";
829
979
  }
830
- function changesForPage(changes, path, accountKey) {
831
- if (!accountKey) return [];
832
- 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
+ }
833
1008
  }
834
1009
  function storage(kind) {
835
1010
  try {
@@ -891,7 +1066,7 @@ function findTextNode(el, match) {
891
1066
  }
892
1067
  function installProductChanges(opts) {
893
1068
  if (typeof window === "undefined" || typeof document === "undefined") {
894
- 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 };
895
1070
  }
896
1071
  let changes = [];
897
1072
  let lastFetch = 0;
@@ -900,6 +1075,11 @@ function installProductChanges(opts) {
900
1075
  let host = null;
901
1076
  const copies = /* @__PURE__ */ new Map();
902
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"));
903
1083
  const log = (message) => {
904
1084
  if (opts.debug) console.warn(`[holdy] ${message}`);
905
1085
  };
@@ -949,21 +1129,66 @@ function installProductChanges(opts) {
949
1129
  document.body.appendChild(host);
950
1130
  return root;
951
1131
  };
952
- 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) => {
953
1140
  if (tips.has(change.id)) return true;
954
- 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;
955
1144
  const shadow = ensureRoot();
956
1145
  if (!shadow) return false;
1146
+ if (host) applyThemeVars(host, readTheme(document, (el) => window.getComputedStyle(el), anchor));
957
1147
  const card = document.createElement("div");
958
- card.className = "tip";
959
- card.setAttribute("role", "tooltip");
1148
+ card.className = asking ? "tip ask" : "tip";
1149
+ card.setAttribute("role", asking ? "dialog" : "tooltip");
960
1150
  const text = document.createElement("p");
961
1151
  text.textContent = change.content.text;
962
1152
  const close = document.createElement("button");
963
1153
  close.type = "button";
964
1154
  close.setAttribute("aria-label", "Fechar");
965
1155
  close.textContent = "\xD7";
966
- 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());
967
1192
  card.style.top = "-9999px";
968
1193
  card.style.left = "-9999px";
969
1194
  shadow.appendChild(card);
@@ -993,6 +1218,11 @@ function installProductChanges(opts) {
993
1218
  card.style.left = `${Math.max(8, left)}px`;
994
1219
  }
995
1220
  close.addEventListener("click", () => {
1221
+ if (preview) {
1222
+ dismissedPreview = true;
1223
+ remove();
1224
+ return;
1225
+ }
996
1226
  addToSet(storage("local"), DISMISSED_KEY, change.id);
997
1227
  opts.track("$change_dismissed", { change_id: change.id, kind: change.kind });
998
1228
  remove();
@@ -1003,20 +1233,143 @@ function installProductChanges(opts) {
1003
1233
  window.addEventListener("resize", position);
1004
1234
  return true;
1005
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
+ };
1006
1340
  const scan = () => {
1007
1341
  timer = null;
1008
- const active = changesForPage(changes, location.pathname, opts.getAccountKey());
1342
+ const active = changesForPage(changes, location.pathname, opts.getAccountKey(), previewId);
1009
1343
  const activeIds = new Set(active.map((a) => a.change.id));
1010
1344
  for (const id of [...copies.keys()]) if (!activeIds.has(id)) restoreCopy(id);
1011
1345
  for (const [id, remove] of [...tips]) if (!activeIds.has(id)) remove();
1012
- 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) {
1013
1353
  const el = findAnchor(change.anchor);
1014
- if (!el && change.anchor.kind !== "page") continue;
1015
- 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") {
1016
1369
  if (!el) continue;
1017
1370
  const shown = arm === "change" ? applyCopy(el, change) : eligibleCopyNode(el, change) !== null;
1018
1371
  if (shown) expose(change, arm);
1019
- } else if (arm === "holdout" || showTip(el, change)) {
1372
+ } else if (arm === "holdout" || showTip(el, change, false)) {
1020
1373
  expose(change, arm);
1021
1374
  }
1022
1375
  }
@@ -1026,8 +1379,14 @@ function installProductChanges(opts) {
1026
1379
  };
1027
1380
  const refresh = async () => {
1028
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();
1029
1388
  try {
1030
- const res = await fetch(`${opts.baseUrl}/runtime/changes`, {
1389
+ const res = await fetch(`${opts.baseUrl}/runtime/changes${qs ? `?${qs}` : ""}`, {
1031
1390
  headers: { Authorization: `Bearer ${opts.apiKey}` }
1032
1391
  });
1033
1392
  if (!res.ok) {
@@ -1052,9 +1411,28 @@ function installProductChanges(opts) {
1052
1411
  void refresh();
1053
1412
  return {
1054
1413
  onNavigate: () => {
1055
- 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();
1056
1423
  schedule();
1057
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
+ },
1058
1436
  refresh,
1059
1437
  teardown: () => {
1060
1438
  clearInterval(interval);
@@ -1062,6 +1440,7 @@ function installProductChanges(opts) {
1062
1440
  observer?.disconnect();
1063
1441
  for (const id of [...copies.keys()]) restoreCopy(id);
1064
1442
  for (const remove of [...tips.values()]) remove();
1443
+ for (const id of [...lists.keys()]) lists.delete(id);
1065
1444
  host?.remove();
1066
1445
  host = null;
1067
1446
  root = null;
@@ -1138,6 +1517,10 @@ var HoldyWeb = class extends HoldyCore {
1138
1517
  super.group(accountKey, traits);
1139
1518
  this.changes?.onNavigate();
1140
1519
  }
1520
+ track(name, properties, overrides) {
1521
+ super.track(name, properties, overrides);
1522
+ if (!name.startsWith("$")) this.changes?.onEvent(name);
1523
+ }
1141
1524
  reset() {
1142
1525
  this.userId = null;
1143
1526
  this.accountKey = null;
@@ -1145,6 +1528,7 @@ var HoldyWeb = class extends HoldyCore {
1145
1528
  localStorage.removeItem(ANON_STORAGE_KEY);
1146
1529
  } catch {
1147
1530
  }
1531
+ clearSessionId();
1148
1532
  this.anonymousId = resolveAnonymousId();
1149
1533
  this.changes?.onNavigate();
1150
1534
  }