@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.
- package/README.md +8 -1
- package/dist/changes.d.ts +16 -2
- package/dist/changes.d.ts.map +1 -1
- package/dist/core.d.ts +1 -0
- package/dist/core.d.ts.map +1 -1
- package/dist/friction.d.ts +2 -1
- package/dist/friction.d.ts.map +1 -1
- package/dist/holdy.js +25 -4
- package/dist/holdy.js.map +4 -4
- package/dist/index.cjs +420 -36
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +420 -36
- package/dist/index.mjs.map +4 -4
- package/dist/server.cjs +3 -0
- package/dist/server.cjs.map +2 -2
- package/dist/server.mjs +3 -0
- package/dist/server.mjs.map +2 -2
- package/dist/skeleton.d.ts +3 -1
- package/dist/skeleton.d.ts.map +1 -1
- package/dist/theme.d.ts +43 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/tip-css.d.ts +9 -0
- package/dist/tip-css.d.ts.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/web-context.d.ts +1 -0
- package/dist/web-context.d.ts.map +1 -1
- package/dist/web.d.ts +2 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.mjs +420 -36
- package/dist/web.mjs.map +4 -4
- package/package.json +1 -1
- package/src/changes.dom.test.ts +221 -0
- package/src/changes.test.ts +38 -0
- package/src/changes.ts +299 -26
- package/src/core.test.ts +15 -0
- package/src/core.ts +3 -0
- package/src/friction.dom.test.ts +54 -0
- package/src/friction.test.ts +43 -0
- package/src/friction.ts +44 -7
- package/src/skeleton.ts +12 -3
- package/src/theme.test.ts +118 -0
- package/src/theme.ts +120 -0
- package/src/tip-css.ts +32 -0
- package/src/types.ts +6 -0
- package/src/web-context.test.ts +18 -0
- package/src/web-context.ts +8 -0
- 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
|
|
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
|
|
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
|
|
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", {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
764
|
+
visible2.push(el);
|
|
657
765
|
}
|
|
658
|
-
const elements =
|
|
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:
|
|
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
|
|
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
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
886
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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")
|
|
1069
|
-
|
|
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
|
-
|
|
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
|
}
|