@holdyapp/sdk 1.5.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +9 -1
  2. package/dist/changes.d.ts +16 -2
  3. package/dist/changes.d.ts.map +1 -1
  4. package/dist/core.d.ts +1 -0
  5. package/dist/core.d.ts.map +1 -1
  6. package/dist/friction.d.ts +2 -1
  7. package/dist/friction.d.ts.map +1 -1
  8. package/dist/holdy.js +25 -4
  9. package/dist/holdy.js.map +4 -4
  10. package/dist/index.cjs +540 -33
  11. package/dist/index.cjs.map +4 -4
  12. package/dist/index.mjs +540 -33
  13. package/dist/index.mjs.map +4 -4
  14. package/dist/server.cjs +3 -0
  15. package/dist/server.cjs.map +2 -2
  16. package/dist/server.mjs +3 -0
  17. package/dist/server.mjs.map +2 -2
  18. package/dist/skeleton.d.ts +52 -0
  19. package/dist/skeleton.d.ts.map +1 -0
  20. package/dist/theme.d.ts +43 -0
  21. package/dist/theme.d.ts.map +1 -0
  22. package/dist/tip-css.d.ts +9 -0
  23. package/dist/tip-css.d.ts.map +1 -0
  24. package/dist/types.d.ts +6 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/web-context.d.ts +1 -0
  27. package/dist/web-context.d.ts.map +1 -1
  28. package/dist/web.d.ts +10 -1
  29. package/dist/web.d.ts.map +1 -1
  30. package/dist/web.mjs +540 -33
  31. package/dist/web.mjs.map +4 -4
  32. package/package.json +1 -1
  33. package/src/changes.dom.test.ts +221 -0
  34. package/src/changes.test.ts +38 -0
  35. package/src/changes.ts +299 -26
  36. package/src/core.test.ts +15 -0
  37. package/src/core.ts +3 -0
  38. package/src/friction.dom.test.ts +54 -0
  39. package/src/friction.test.ts +43 -0
  40. package/src/friction.ts +44 -7
  41. package/src/skeleton.test.ts +60 -0
  42. package/src/skeleton.ts +182 -0
  43. package/src/theme.test.ts +118 -0
  44. package/src/theme.ts +120 -0
  45. package/src/tip-css.ts +32 -0
  46. package/src/types.ts +6 -0
  47. package/src/web-context.test.ts +18 -0
  48. package/src/web-context.ts +8 -0
  49. package/src/web.ts +28 -3
package/dist/index.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,200 @@ 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
+
741
+ // src/skeleton.ts
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]';
743
+ var SKELETON_MAX_ELEMENTS = 80;
744
+ var SKELETON_DELAY_MS = 1500;
745
+ var SENT_STORAGE_KEY = "holdy_skeletons";
746
+ var SENT_STORAGE_MAX = 200;
747
+ function fnv1a(input) {
748
+ let hash = 2166136261;
749
+ for (let i = 0; i < input.length; i++) {
750
+ hash ^= input.charCodeAt(i);
751
+ hash = Math.imul(hash, 16777619) >>> 0;
752
+ }
753
+ return hash.toString(16).padStart(8, "0");
754
+ }
755
+ function attr(el, name, max) {
756
+ const value = el.getAttribute(name);
757
+ if (!value) return void 0;
758
+ const trimmed = value.replace(/\s+/g, " ").trim().slice(0, max);
759
+ return trimmed || void 0;
760
+ }
761
+ function isVisible(el) {
762
+ if (!el.getBoundingClientRect) return true;
763
+ const rect = el.getBoundingClientRect();
764
+ return rect.width > 0 && rect.height > 0;
765
+ }
766
+ function relativeBox(rect, viewport) {
767
+ const pct = (value, base) => base > 0 ? Math.max(0, Math.min(100, Math.round(value / base * 100))) : 0;
768
+ return [pct(rect.x, viewport.width), pct(rect.y + viewport.scrollY, viewport.height), pct(rect.width, viewport.width), pct(rect.height, viewport.height)];
769
+ }
770
+ function describeSkeletonElement(el, viewport) {
771
+ const out = { t: el.tagName.toLowerCase() };
772
+ const role = attr(el, "role", 40);
773
+ if (role) out.r = role;
774
+ const testid = attr(el, "data-testid", 80);
775
+ if (testid) out.id = testid;
776
+ const name = attr(el, "name", 80);
777
+ if (name) out.n = name;
778
+ const label = attr(el, "aria-label", 80);
779
+ if (label) out.l = label;
780
+ if (out.t === "input" || out.t === "button") {
781
+ const type = attr(el, "type", 20);
782
+ if (type) out.k = type.toLowerCase();
783
+ }
784
+ if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport);
785
+ return out;
786
+ }
787
+ function collectSkeleton(root, path, viewport, theme = null) {
788
+ const found = root.querySelectorAll(SKELETON_SELECTOR);
789
+ const visible2 = [];
790
+ for (let i = 0; i < found.length; i++) {
791
+ const el = found[i];
792
+ if (el.tagName.toLowerCase() === "input" && (el.getAttribute("type") ?? "").toLowerCase() === "hidden") continue;
793
+ if (!isVisible(el)) continue;
794
+ visible2.push(el);
795
+ }
796
+ const elements = visible2.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport));
797
+ const structural = elements.map(({ b: _b, ...rest }) => rest);
798
+ return { path, hash: fnv1a(JSON.stringify(structural)), count: visible2.length, elements, ...theme ? { theme } : {} };
799
+ }
800
+ function loadSent() {
801
+ try {
802
+ const raw = sessionStorage.getItem(SENT_STORAGE_KEY);
803
+ const list = raw ? JSON.parse(raw) : [];
804
+ return new Set(Array.isArray(list) ? list.filter((x) => typeof x === "string") : []);
805
+ } catch {
806
+ return /* @__PURE__ */ new Set();
807
+ }
808
+ }
809
+ function saveSent(sent) {
810
+ try {
811
+ sessionStorage.setItem(SENT_STORAGE_KEY, JSON.stringify([...sent].slice(-SENT_STORAGE_MAX)));
812
+ } catch {
813
+ return;
814
+ }
815
+ }
816
+ function installSkeletonCapture(opts) {
817
+ if (typeof window === "undefined" || typeof document === "undefined") {
818
+ return { onNavigate: () => void 0, teardown: () => void 0 };
819
+ }
820
+ const delay = opts.delayMs ?? SKELETON_DELAY_MS;
821
+ const sent = loadSent();
822
+ let pending = null;
823
+ const capture = (path) => {
824
+ pending = null;
825
+ if (location.pathname !== path) return;
826
+ try {
827
+ const viewport = {
828
+ width: window.innerWidth,
829
+ height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
830
+ scrollY: window.scrollY
831
+ };
832
+ const theme = readTheme(document, (el) => window.getComputedStyle(el));
833
+ const skeleton = collectSkeleton(document, path, viewport, theme);
834
+ const key = `${path}:${skeleton.hash}`;
835
+ if (sent.has(key)) return;
836
+ sent.add(key);
837
+ saveSent(sent);
838
+ opts.onSkeleton(skeleton);
839
+ } catch {
840
+ return;
841
+ }
842
+ };
843
+ return {
844
+ onNavigate: (path) => {
845
+ if (pending) clearTimeout(pending);
846
+ pending = setTimeout(() => capture(path), delay);
847
+ },
848
+ teardown: () => {
849
+ if (pending) clearTimeout(pending);
850
+ pending = null;
851
+ }
852
+ };
853
+ }
854
+
633
855
  // src/web-context.ts
634
856
  var UTM_KEYS = [
635
857
  "utm_source",
@@ -694,6 +916,12 @@ function randomId() {
694
916
  return `hol-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
695
917
  }
696
918
  }
919
+ function clearSessionId() {
920
+ try {
921
+ sessionStorage.removeItem(SESSION_STORAGE_KEY);
922
+ } catch {
923
+ }
924
+ }
697
925
  function resolveSessionId() {
698
926
  const existing = readStorage("session", SESSION_STORAGE_KEY);
699
927
  if (existing) return existing;
@@ -747,8 +975,35 @@ function buildWebContext() {
747
975
  };
748
976
  }
749
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
+
750
1005
  // src/arm.ts
751
- function fnv1a(input) {
1006
+ function fnv1a2(input) {
752
1007
  let hash = 2166136261;
753
1008
  for (let i = 0; i < input.length; i++) {
754
1009
  hash ^= input.charCodeAt(i);
@@ -757,7 +1012,7 @@ function fnv1a(input) {
757
1012
  return hash >>> 0;
758
1013
  }
759
1014
  function changeBucket(changeId, accountKey) {
760
- return fnv1a(`${changeId}:${accountKey}`) % 100;
1015
+ return fnv1a2(`${changeId}:${accountKey}`) % 100;
761
1016
  }
762
1017
  function changeArm(changeId, accountKey, holdoutPct) {
763
1018
  return changeBucket(changeId, accountKey) < holdoutPct ? "holdout" : "change";
@@ -776,12 +1031,16 @@ var SCAN_DEBOUNCE_MS = 250;
776
1031
  var SEEN_KEY = "holdy_changes_seen";
777
1032
  var DISMISSED_KEY = "holdy_changes_dismissed";
778
1033
  var TIP_GAP_PX = 8;
779
- var TIP_CSS = `:host{all:initial}
780
- .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}
781
- .tip p{margin:0;flex:1}
782
- .tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1}
783
- .tip button:hover,.tip button:focus-visible{opacity:1}
784
- @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;
785
1044
  function normalizeText(text) {
786
1045
  return text.replace(/\s+/g, " ").trim();
787
1046
  }
@@ -794,13 +1053,42 @@ function isAnchor(value) {
794
1053
  if (a?.kind === "page") return true;
795
1054
  return (a?.kind === "selector" || a?.kind === "text") && typeof a.value === "string" && a.value.length > 0;
796
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
+ }
797
1060
  function isChange(value) {
798
1061
  const c = value;
799
- 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";
800
1063
  }
801
- function changesForPage(changes, path, accountKey) {
802
- if (!accountKey) return [];
803
- 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
+ }
804
1092
  }
805
1093
  function storage(kind) {
806
1094
  try {
@@ -862,7 +1150,7 @@ function findTextNode(el, match) {
862
1150
  }
863
1151
  function installProductChanges(opts) {
864
1152
  if (typeof window === "undefined" || typeof document === "undefined") {
865
- 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 };
866
1154
  }
867
1155
  let changes = [];
868
1156
  let lastFetch = 0;
@@ -871,6 +1159,11 @@ function installProductChanges(opts) {
871
1159
  let host = null;
872
1160
  const copies = /* @__PURE__ */ new Map();
873
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"));
874
1167
  const log = (message) => {
875
1168
  if (opts.debug) console.warn(`[holdy] ${message}`);
876
1169
  };
@@ -920,21 +1213,66 @@ function installProductChanges(opts) {
920
1213
  document.body.appendChild(host);
921
1214
  return root;
922
1215
  };
923
- 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) => {
924
1224
  if (tips.has(change.id)) return true;
925
- 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;
926
1228
  const shadow = ensureRoot();
927
1229
  if (!shadow) return false;
1230
+ if (host) applyThemeVars(host, readTheme(document, (el) => window.getComputedStyle(el), anchor));
928
1231
  const card = document.createElement("div");
929
- card.className = "tip";
930
- card.setAttribute("role", "tooltip");
1232
+ card.className = asking ? "tip ask" : "tip";
1233
+ card.setAttribute("role", asking ? "dialog" : "tooltip");
931
1234
  const text = document.createElement("p");
932
1235
  text.textContent = change.content.text;
933
1236
  const close = document.createElement("button");
934
1237
  close.type = "button";
935
1238
  close.setAttribute("aria-label", "Fechar");
936
1239
  close.textContent = "\xD7";
937
- 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());
938
1276
  card.style.top = "-9999px";
939
1277
  card.style.left = "-9999px";
940
1278
  shadow.appendChild(card);
@@ -964,6 +1302,11 @@ function installProductChanges(opts) {
964
1302
  card.style.left = `${Math.max(8, left)}px`;
965
1303
  }
966
1304
  close.addEventListener("click", () => {
1305
+ if (preview) {
1306
+ dismissedPreview = true;
1307
+ remove();
1308
+ return;
1309
+ }
967
1310
  addToSet(storage("local"), DISMISSED_KEY, change.id);
968
1311
  opts.track("$change_dismissed", { change_id: change.id, kind: change.kind });
969
1312
  remove();
@@ -974,20 +1317,143 @@ function installProductChanges(opts) {
974
1317
  window.addEventListener("resize", position);
975
1318
  return true;
976
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
+ };
977
1424
  const scan = () => {
978
1425
  timer = null;
979
- const active = changesForPage(changes, location.pathname, opts.getAccountKey());
1426
+ const active = changesForPage(changes, location.pathname, opts.getAccountKey(), previewId);
980
1427
  const activeIds = new Set(active.map((a) => a.change.id));
981
1428
  for (const id of [...copies.keys()]) if (!activeIds.has(id)) restoreCopy(id);
982
1429
  for (const [id, remove] of [...tips]) if (!activeIds.has(id)) remove();
983
- 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) {
984
1437
  const el = findAnchor(change.anchor);
985
- if (!el && change.anchor.kind !== "page") continue;
986
- 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") {
987
1453
  if (!el) continue;
988
1454
  const shown = arm === "change" ? applyCopy(el, change) : eligibleCopyNode(el, change) !== null;
989
1455
  if (shown) expose(change, arm);
990
- } else if (arm === "holdout" || showTip(el, change)) {
1456
+ } else if (arm === "holdout" || showTip(el, change, false)) {
991
1457
  expose(change, arm);
992
1458
  }
993
1459
  }
@@ -997,8 +1463,14 @@ function installProductChanges(opts) {
997
1463
  };
998
1464
  const refresh = async () => {
999
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();
1000
1472
  try {
1001
- const res = await fetch(`${opts.baseUrl}/runtime/changes`, {
1473
+ const res = await fetch(`${opts.baseUrl}/runtime/changes${qs ? `?${qs}` : ""}`, {
1002
1474
  headers: { Authorization: `Bearer ${opts.apiKey}` }
1003
1475
  });
1004
1476
  if (!res.ok) {
@@ -1023,9 +1495,28 @@ function installProductChanges(opts) {
1023
1495
  void refresh();
1024
1496
  return {
1025
1497
  onNavigate: () => {
1026
- 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();
1027
1507
  schedule();
1028
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
+ },
1029
1520
  refresh,
1030
1521
  teardown: () => {
1031
1522
  clearInterval(interval);
@@ -1033,6 +1524,7 @@ function installProductChanges(opts) {
1033
1524
  observer?.disconnect();
1034
1525
  for (const id of [...copies.keys()]) restoreCopy(id);
1035
1526
  for (const remove of [...tips.values()]) remove();
1527
+ for (const id of [...lists.keys()]) lists.delete(id);
1036
1528
  host?.remove();
1037
1529
  host = null;
1038
1530
  root = null;
@@ -1047,6 +1539,7 @@ var HoldyWeb = class extends HoldyCore {
1047
1539
  teardownAutoCapture = null;
1048
1540
  friction = null;
1049
1541
  changes = null;
1542
+ skeleton = null;
1050
1543
  constructor(opts) {
1051
1544
  super("sdk_web", opts);
1052
1545
  this.anonymousId = resolveAnonymousId();
@@ -1064,12 +1557,19 @@ var HoldyWeb = class extends HoldyCore {
1064
1557
  onFriction: (signal) => this.track(`$${signal.kind}`, signal.properties)
1065
1558
  });
1066
1559
  }
1560
+ if (opts.skeleton !== false) {
1561
+ this.skeleton = installSkeletonCapture({
1562
+ onSkeleton: (skeleton) => this.track("$page_skeleton", { ...skeleton })
1563
+ });
1564
+ }
1067
1565
  if (opts.autoCapture !== false) {
1068
1566
  this.teardownAutoCapture = installAutoCapture({
1069
1567
  onNavigate: (kind) => {
1070
1568
  this.page(void 0, { navigation: kind });
1071
- this.friction?.onNavigate(typeof location !== "undefined" ? location.pathname : "/");
1569
+ const path = typeof location !== "undefined" ? location.pathname : "/";
1570
+ this.friction?.onNavigate(path);
1072
1571
  this.changes?.onNavigate();
1572
+ this.skeleton?.onNavigate(path);
1073
1573
  }
1074
1574
  });
1075
1575
  }
@@ -1101,6 +1601,10 @@ var HoldyWeb = class extends HoldyCore {
1101
1601
  super.group(accountKey, traits);
1102
1602
  this.changes?.onNavigate();
1103
1603
  }
1604
+ track(name, properties, overrides) {
1605
+ super.track(name, properties, overrides);
1606
+ if (!name.startsWith("$")) this.changes?.onEvent(name);
1607
+ }
1104
1608
  reset() {
1105
1609
  this.userId = null;
1106
1610
  this.accountKey = null;
@@ -1108,6 +1612,7 @@ var HoldyWeb = class extends HoldyCore {
1108
1612
  localStorage.removeItem(ANON_STORAGE_KEY);
1109
1613
  } catch {
1110
1614
  }
1615
+ clearSessionId();
1111
1616
  this.anonymousId = resolveAnonymousId();
1112
1617
  this.changes?.onNavigate();
1113
1618
  }
@@ -1119,6 +1624,8 @@ var HoldyWeb = class extends HoldyCore {
1119
1624
  this.friction = null;
1120
1625
  this.changes?.teardown();
1121
1626
  this.changes = null;
1627
+ this.skeleton?.teardown();
1628
+ this.skeleton = null;
1122
1629
  await super.shutdown();
1123
1630
  }
1124
1631
  environmentContext() {