@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/src/friction.ts
CHANGED
|
@@ -79,7 +79,7 @@ export class NavLoopDetector {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export interface ElementLike {
|
|
82
|
-
tagName
|
|
82
|
+
tagName?: string
|
|
83
83
|
id?: string
|
|
84
84
|
disabled?: boolean
|
|
85
85
|
textContent?: string | null
|
|
@@ -87,11 +87,15 @@ export interface ElementLike {
|
|
|
87
87
|
closest?(selector: string): ElementLike | null
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
function tagOf(el: { tagName?: unknown } | null | undefined): string {
|
|
91
|
+
return typeof el?.tagName === 'string' ? el.tagName.toLowerCase() : ''
|
|
92
|
+
}
|
|
93
|
+
|
|
90
94
|
export function resolveInteractive(el: ElementLike | null): ElementLike | null {
|
|
91
95
|
if (!el) return null
|
|
92
96
|
const viaClosest = el.closest?.(INTERACTIVE_SELECTOR) ?? null
|
|
93
97
|
if (viaClosest) return viaClosest
|
|
94
|
-
const tag = el
|
|
98
|
+
const tag = tagOf(el)
|
|
95
99
|
if (INTERACTIVE_TAGS.has(tag)) return el
|
|
96
100
|
const role = el.getAttribute('role')
|
|
97
101
|
if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') return el
|
|
@@ -104,7 +108,7 @@ export function isDeadClickTarget(el: ElementLike | null): boolean {
|
|
|
104
108
|
if (target.disabled === true) return true
|
|
105
109
|
if (target.getAttribute('disabled') !== null) return true
|
|
106
110
|
if (target.getAttribute('aria-disabled') === 'true') return true
|
|
107
|
-
const tag = target
|
|
111
|
+
const tag = tagOf(target)
|
|
108
112
|
if (tag === 'a') {
|
|
109
113
|
const href = target.getAttribute('href')
|
|
110
114
|
if (!href || href === '#' || href.startsWith('javascript:')) return true
|
|
@@ -115,7 +119,7 @@ export function isDeadClickTarget(el: ElementLike | null): boolean {
|
|
|
115
119
|
export function describeElement(el: ElementLike | null): Record<string, string> {
|
|
116
120
|
const target = resolveInteractive(el) ?? el
|
|
117
121
|
if (!target) return {}
|
|
118
|
-
const out: Record<string, string> = { element: target
|
|
122
|
+
const out: Record<string, string> = { element: tagOf(target) }
|
|
119
123
|
if (target.id) out.element_id = target.id
|
|
120
124
|
const role = target.getAttribute('role')
|
|
121
125
|
if (role) out.element_role = role
|
|
@@ -146,6 +150,33 @@ export function errorMessage(reason: unknown): string {
|
|
|
146
150
|
}
|
|
147
151
|
}
|
|
148
152
|
|
|
153
|
+
const STACK_MAX_FRAMES = 8
|
|
154
|
+
const STACK_MAX_FRAME_CHARS = 200
|
|
155
|
+
const STACK_FRAME = /^at\s|^\S*@\S+:\d+(?::\d+)?$/
|
|
156
|
+
|
|
157
|
+
export function sanitizeStack(stack: unknown): string | null {
|
|
158
|
+
if (typeof stack !== 'string' || !stack) return null
|
|
159
|
+
const frames = stack
|
|
160
|
+
.split('\n')
|
|
161
|
+
.map((line) => line.trim())
|
|
162
|
+
.filter((line) => line.length > 0 && STACK_FRAME.test(line))
|
|
163
|
+
.slice(0, STACK_MAX_FRAMES)
|
|
164
|
+
.map((line) =>
|
|
165
|
+
line
|
|
166
|
+
.replace(/(https?:\/\/[^\s)?#]+)[?#][^\s)]*?(?=(?::\d+){0,2}\)?$)/, '$1')
|
|
167
|
+
.slice(0, STACK_MAX_FRAME_CHARS),
|
|
168
|
+
)
|
|
169
|
+
return frames.length > 0 ? frames.join('\n') : null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function withStack(stack: string | null): { stack?: string } {
|
|
173
|
+
return stack ? { stack } : {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function stackOf(reason: unknown): string | null {
|
|
177
|
+
return typeof reason === 'object' && reason !== null ? sanitizeStack((reason as { stack?: unknown }).stack) : null
|
|
178
|
+
}
|
|
179
|
+
|
|
149
180
|
export function isAbortError(err: unknown): boolean {
|
|
150
181
|
return typeof err === 'object' && err !== null && (err as { name?: unknown }).name === 'AbortError'
|
|
151
182
|
}
|
|
@@ -278,13 +309,19 @@ export function installFrictionCapture(opts: FrictionCaptureOptions): FrictionCa
|
|
|
278
309
|
message: String(ev.message ?? '').slice(0, 200),
|
|
279
310
|
...(ev.filename ? { source: stripRequestUrl(ev.filename, location.href) } : {}),
|
|
280
311
|
...(ev.lineno ? { line: ev.lineno } : {}),
|
|
312
|
+
...(ev.colno ? { column: ev.colno } : {}),
|
|
313
|
+
...withStack(stackOf(ev.error)),
|
|
281
314
|
})
|
|
282
315
|
}
|
|
283
316
|
window.addEventListener('error', onError)
|
|
284
317
|
cleanups.push(() => window.removeEventListener('error', onError))
|
|
285
318
|
|
|
286
319
|
const onRejection = (ev: PromiseRejectionEvent): void => {
|
|
287
|
-
emit('js_error', {
|
|
320
|
+
emit('js_error', {
|
|
321
|
+
message: errorMessage(ev.reason).slice(0, 200),
|
|
322
|
+
unhandled_rejection: true,
|
|
323
|
+
...withStack(stackOf(ev.reason)),
|
|
324
|
+
})
|
|
288
325
|
}
|
|
289
326
|
window.addEventListener('unhandledrejection', onRejection)
|
|
290
327
|
cleanups.push(() => window.removeEventListener('unhandledrejection', onRejection))
|
|
@@ -378,11 +415,11 @@ export function installFrictionCapture(opts: FrictionCaptureOptions): FrictionCa
|
|
|
378
415
|
|
|
379
416
|
const onFocusIn = (ev: FocusEvent): void => {
|
|
380
417
|
const el = ev.target as HTMLElement | null
|
|
381
|
-
if (!el || !FORM_CONTROL_TAGS.has(el
|
|
418
|
+
if (!el || !FORM_CONTROL_TAGS.has(tagOf(el))) return
|
|
382
419
|
const form = (el as HTMLInputElement).form
|
|
383
420
|
if (!form) return
|
|
384
421
|
const entry = touched.get(form) ?? { form: formKey(form), fields: new Set<string>() }
|
|
385
|
-
const field = (el as HTMLInputElement).name || el.id || el
|
|
422
|
+
const field = (el as HTMLInputElement).name || el.id || tagOf(el)
|
|
386
423
|
entry.fields.add(field)
|
|
387
424
|
touched.set(form, entry)
|
|
388
425
|
}
|
package/src/skeleton.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readTheme, type PageTheme } from './theme'
|
|
2
|
+
|
|
1
3
|
export const SKELETON_SELECTOR =
|
|
2
4
|
'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [data-testid]'
|
|
3
5
|
export const SKELETON_MAX_ELEMENTS = 80
|
|
@@ -43,6 +45,7 @@ export interface PageSkeleton {
|
|
|
43
45
|
hash: string
|
|
44
46
|
count: number
|
|
45
47
|
elements: SkeletonElement[]
|
|
48
|
+
theme?: PageTheme
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export function fnv1a(input: string): string {
|
|
@@ -90,7 +93,12 @@ export function describeSkeletonElement(el: SkeletonElementLike, viewport: Skele
|
|
|
90
93
|
return out
|
|
91
94
|
}
|
|
92
95
|
|
|
93
|
-
export function collectSkeleton(
|
|
96
|
+
export function collectSkeleton(
|
|
97
|
+
root: SkeletonRootLike,
|
|
98
|
+
path: string,
|
|
99
|
+
viewport: SkeletonViewport | null,
|
|
100
|
+
theme: PageTheme | null = null,
|
|
101
|
+
): PageSkeleton {
|
|
94
102
|
const found = root.querySelectorAll(SKELETON_SELECTOR)
|
|
95
103
|
const visible: SkeletonElementLike[] = []
|
|
96
104
|
for (let i = 0; i < found.length; i++) {
|
|
@@ -101,7 +109,7 @@ export function collectSkeleton(root: SkeletonRootLike, path: string, viewport:
|
|
|
101
109
|
}
|
|
102
110
|
const elements = visible.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport))
|
|
103
111
|
const structural = elements.map(({ b: _b, ...rest }) => rest)
|
|
104
|
-
return { path, hash: fnv1a(JSON.stringify(structural)), count: visible.length, elements }
|
|
112
|
+
return { path, hash: fnv1a(JSON.stringify(structural)), count: visible.length, elements, ...(theme ? { theme } : {}) }
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
export interface SkeletonCaptureOptions {
|
|
@@ -149,7 +157,8 @@ export function installSkeletonCapture(opts: SkeletonCaptureOptions): SkeletonCa
|
|
|
149
157
|
height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
|
|
150
158
|
scrollY: window.scrollY,
|
|
151
159
|
}
|
|
152
|
-
const
|
|
160
|
+
const theme = readTheme(document, (el) => window.getComputedStyle(el as Element))
|
|
161
|
+
const skeleton = collectSkeleton(document, path, viewport, theme)
|
|
153
162
|
const key = `${path}:${skeleton.hash}`
|
|
154
163
|
if (sent.has(key)) return
|
|
155
164
|
sent.add(key)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { collectSkeleton } from './skeleton'
|
|
3
|
+
import { applyThemeVars, cleanColor, cleanFont, cleanPx, readTheme, themeVars, type ThemeElementLike } from './theme'
|
|
4
|
+
|
|
5
|
+
type Styles = Record<string, string>
|
|
6
|
+
|
|
7
|
+
function el(tagName: string, styles: Styles, size = 1): ThemeElementLike & { styles: Styles } {
|
|
8
|
+
return { tagName, styles, getBoundingClientRect: () => ({ width: size, height: size }) }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const style = (e: ThemeElementLike) => ({ getPropertyValue: (name: string) => (e as unknown as { styles: Styles }).styles[name] ?? '' })
|
|
12
|
+
|
|
13
|
+
function doc(body: Styles, buttons: Array<ReturnType<typeof el>> = [], root: Styles = {}) {
|
|
14
|
+
return {
|
|
15
|
+
body: el('body', body),
|
|
16
|
+
documentElement: el('html', root),
|
|
17
|
+
querySelectorAll: () => buttons,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('readTheme', () => {
|
|
22
|
+
it('lê fonte, tamanho, cores do body e o botão primário com o raio', () => {
|
|
23
|
+
const primary = el('button', { 'background-color': 'rgb(37, 99, 235)', color: 'rgb(255, 255, 255)', 'border-top-left-radius': '6px' })
|
|
24
|
+
const theme = readTheme(
|
|
25
|
+
doc({ 'font-family': '"Inter", ui-sans-serif, system-ui', 'font-size': '14px', color: 'rgb(23, 23, 23)', 'background-color': 'rgb(255, 255, 255)' }, [primary]),
|
|
26
|
+
style,
|
|
27
|
+
)
|
|
28
|
+
expect(theme).toEqual({
|
|
29
|
+
font: 'Inter',
|
|
30
|
+
size: '14px',
|
|
31
|
+
fg: 'rgb(23, 23, 23)',
|
|
32
|
+
bg: 'rgb(255, 255, 255)',
|
|
33
|
+
accent: 'rgb(37, 99, 235)',
|
|
34
|
+
accentFg: 'rgb(255, 255, 255)',
|
|
35
|
+
radius: '6px',
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('body transparente cai pro fundo do html; botão invisível ou da cor do fundo não vira acento', () => {
|
|
40
|
+
const ghost = el('button', { 'background-color': 'rgb(250, 250, 250)', color: 'rgb(0, 0, 0)' })
|
|
41
|
+
const hidden = el('button', { 'background-color': 'rgb(0, 0, 0)', color: 'rgb(255, 255, 255)' }, 0)
|
|
42
|
+
const theme = readTheme(doc({ 'background-color': 'rgba(0, 0, 0, 0)', color: 'rgb(10, 10, 10)' }, [hidden, ghost], { 'background-color': 'rgb(250, 250, 250)' }), style)
|
|
43
|
+
expect(theme).toEqual({ fg: 'rgb(10, 10, 10)', bg: 'rgb(250, 250, 250)' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('entre vários botões vence o que mais contrasta com o fundo; botão branco em fundo claro não é acento', () => {
|
|
47
|
+
const white = el('button', { 'background-color': 'rgb(255, 255, 255)', color: 'rgb(8, 8, 10)', 'border-top-left-radius': '7px' })
|
|
48
|
+
const black = el('button', { 'background-color': 'rgb(23, 23, 26)', color: 'rgb(255, 255, 255)', 'border-top-left-radius': '7px' })
|
|
49
|
+
const theme = readTheme(doc({ 'background-color': 'rgb(246, 246, 244)' }, [white, black]), style)
|
|
50
|
+
expect(theme?.accent).toBe('rgb(23, 23, 26)')
|
|
51
|
+
expect(theme?.accentFg).toBe('rgb(255, 255, 255)')
|
|
52
|
+
const only = readTheme(doc({ 'background-color': 'rgb(246, 246, 244)' }, [white]), style)
|
|
53
|
+
expect(only?.accent).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('o raio vem do próprio elemento âncora quando ele é um botão', () => {
|
|
57
|
+
const primary = el('button', { 'background-color': 'rgb(0, 0, 0)', color: 'rgb(255, 255, 255)', 'border-top-left-radius': '999px' })
|
|
58
|
+
const anchor = el('button', { 'border-top-left-radius': '4px' })
|
|
59
|
+
const theme = readTheme(doc({ 'background-color': 'rgb(255, 255, 255)' }, [primary]), style, anchor)
|
|
60
|
+
expect(theme?.radius).toBe('4px')
|
|
61
|
+
expect(theme?.accent).toBe('rgb(0, 0, 0)')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('sem body ou sem nada legível devolve null', () => {
|
|
65
|
+
expect(readTheme({ body: null, documentElement: el('html', {}), querySelectorAll: () => [] }, style)).toBeNull()
|
|
66
|
+
expect(readTheme(doc({}), style)).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('limpeza dos valores', () => {
|
|
71
|
+
it('cor só em formato de computed style, nunca texto livre', () => {
|
|
72
|
+
expect(cleanColor('rgb(1, 2, 3)')).toBe('rgb(1, 2, 3)')
|
|
73
|
+
expect(cleanColor('oklch(0.6 0.1 250)')).toBe('oklch(0.6 0.1 250)')
|
|
74
|
+
expect(cleanColor('transparent')).toBeUndefined()
|
|
75
|
+
expect(cleanColor('rgba(0, 0, 0, 0)')).toBeUndefined()
|
|
76
|
+
expect(cleanColor('url(javascript:alert(1))')).toBeUndefined()
|
|
77
|
+
expect(cleanColor('red; background: url(x)')).toBeUndefined()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('fonte é a primeira família, sem aspas, e só letras', () => {
|
|
81
|
+
expect(cleanFont('"Söhne", sans-serif')).toBeUndefined()
|
|
82
|
+
expect(cleanFont("'Geist Sans', system-ui")).toBe('Geist Sans')
|
|
83
|
+
expect(cleanFont('Inter')).toBe('Inter')
|
|
84
|
+
expect(cleanFont('')).toBeUndefined()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('pixel dentro da faixa, arredondado', () => {
|
|
88
|
+
expect(cleanPx('13.5px', 10, 20)).toBe('14px')
|
|
89
|
+
expect(cleanPx('32px', 10, 20)).toBeUndefined()
|
|
90
|
+
expect(cleanPx('1rem', 10, 20)).toBeUndefined()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('themeVars e applyThemeVars', () => {
|
|
95
|
+
it('vira variáveis --holdy-* só com o que foi lido, e limpa o que havia antes', () => {
|
|
96
|
+
expect(themeVars({ font: 'Inter', accent: 'rgb(0, 0, 0)' })).toEqual([
|
|
97
|
+
['--holdy-font', 'Inter'],
|
|
98
|
+
['--holdy-accent', 'rgb(0, 0, 0)'],
|
|
99
|
+
])
|
|
100
|
+
const set: Record<string, string> = { '--holdy-bg': 'old' }
|
|
101
|
+
const host = { style: { setProperty: (n: string, v: string) => void (set[n] = v), removeProperty: (n: string) => void delete set[n] } }
|
|
102
|
+
applyThemeVars(host, { fg: 'rgb(1, 1, 1)' })
|
|
103
|
+
expect(set).toEqual({ '--holdy-fg': 'rgb(1, 1, 1)' })
|
|
104
|
+
applyThemeVars(host, null)
|
|
105
|
+
expect(set).toEqual({})
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('esqueleto com tema', () => {
|
|
110
|
+
it('o tema viaja no esqueleto sem entrar no hash', () => {
|
|
111
|
+
const root = { querySelectorAll: () => [{ tagName: 'BUTTON', getAttribute: (n: string) => (n === 'data-testid' ? 'save' : null) }] }
|
|
112
|
+
const plain = collectSkeleton(root, '/x', null)
|
|
113
|
+
const themed = collectSkeleton(root, '/x', null, { font: 'Inter' })
|
|
114
|
+
expect(themed.hash).toBe(plain.hash)
|
|
115
|
+
expect(themed.theme).toEqual({ font: 'Inter' })
|
|
116
|
+
expect(plain.theme).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
})
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export interface PageTheme {
|
|
2
|
+
font?: string
|
|
3
|
+
size?: string
|
|
4
|
+
fg?: string
|
|
5
|
+
bg?: string
|
|
6
|
+
accent?: string
|
|
7
|
+
accentFg?: string
|
|
8
|
+
radius?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ThemeStyleLike {
|
|
12
|
+
getPropertyValue(name: string): string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ThemeElementLike {
|
|
16
|
+
tagName: string
|
|
17
|
+
getBoundingClientRect?(): { width: number; height: number }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ThemeDocLike {
|
|
21
|
+
body: ThemeElementLike | null
|
|
22
|
+
documentElement: ThemeElementLike
|
|
23
|
+
querySelectorAll(selector: string): ArrayLike<ThemeElementLike>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const THEME_BUTTON_SELECTOR = 'button[type="submit"],[data-testid*="primary"],.btn-primary,button,[role="button"],a.button'
|
|
27
|
+
export const THEME_MAX_BUTTONS = 40
|
|
28
|
+
export const THEME_KEYS: readonly (keyof PageTheme)[] = ['font', 'size', 'fg', 'bg', 'accent', 'accentFg', 'radius']
|
|
29
|
+
|
|
30
|
+
const COLOR = /^(rgba?\([\d.,\s%/]{1,40}\)|#[0-9a-fA-F]{3,8}|(?:color|oklch|oklab|hsla?|lab|lch)\([^()]{1,60}\))$/
|
|
31
|
+
/** Distância mínima entre o botão e o fundo pra valer como cor primária: um botão branco em fundo claro não é acento. */
|
|
32
|
+
export const MIN_ACCENT_CONTRAST = 60
|
|
33
|
+
const RGB = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/
|
|
34
|
+
|
|
35
|
+
export function colorDistance(a: string, b: string | undefined): number {
|
|
36
|
+
const x = RGB.exec(a)
|
|
37
|
+
const y = b ? RGB.exec(b) : null
|
|
38
|
+
if (!x) return MIN_ACCENT_CONTRAST + 1
|
|
39
|
+
if (!y) return MIN_ACCENT_CONTRAST + 1
|
|
40
|
+
return Math.abs(+x[1] - +y[1]) + Math.abs(+x[2] - +y[2]) + Math.abs(+x[3] - +y[3])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const 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*\))$/
|
|
44
|
+
|
|
45
|
+
export function themeVarName(key: keyof PageTheme): string {
|
|
46
|
+
return `--holdy-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function cleanColor(value: string | null | undefined): string | undefined {
|
|
50
|
+
const v = (value ?? '').trim()
|
|
51
|
+
return v && v.length <= 80 && !TRANSPARENT.test(v) && COLOR.test(v) ? v : undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function cleanFont(value: string | null | undefined): string | undefined {
|
|
55
|
+
const first = (value ?? '').split(',')[0].trim().replace(/^["']|["']$/g, '').trim()
|
|
56
|
+
return /^[\w\s-]{1,60}$/.test(first) ? first : undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function cleanPx(value: string | null | undefined, min: number, max: number): string | undefined {
|
|
60
|
+
const m = /^(\d+(?:\.\d+)?)px/.exec((value ?? '').trim())
|
|
61
|
+
const n = m ? Math.round(Number(m[1])) : NaN
|
|
62
|
+
return n >= min && n <= max ? `${n}px` : undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function visible(el: ThemeElementLike): boolean {
|
|
66
|
+
const r = el.getBoundingClientRect?.()
|
|
67
|
+
return !r || (r.width > 0 && r.height > 0)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readTheme(
|
|
71
|
+
doc: ThemeDocLike,
|
|
72
|
+
getStyle: (el: ThemeElementLike) => ThemeStyleLike,
|
|
73
|
+
anchor: ThemeElementLike | null = null,
|
|
74
|
+
): PageTheme | null {
|
|
75
|
+
const body = doc.body
|
|
76
|
+
if (!body) return null
|
|
77
|
+
const bodyStyle = getStyle(body)
|
|
78
|
+
const theme: PageTheme = {}
|
|
79
|
+
const put = (key: keyof PageTheme, value: string | undefined) => {
|
|
80
|
+
if (value) theme[key] = value
|
|
81
|
+
}
|
|
82
|
+
put('font', cleanFont(bodyStyle.getPropertyValue('font-family')))
|
|
83
|
+
put('size', cleanPx(bodyStyle.getPropertyValue('font-size'), 10, 20))
|
|
84
|
+
put('fg', cleanColor(bodyStyle.getPropertyValue('color')))
|
|
85
|
+
put('bg', cleanColor(bodyStyle.getPropertyValue('background-color')) ?? cleanColor(getStyle(doc.documentElement).getPropertyValue('background-color')))
|
|
86
|
+
|
|
87
|
+
const candidates = doc.querySelectorAll(THEME_BUTTON_SELECTOR)
|
|
88
|
+
let button: ThemeElementLike | null = null
|
|
89
|
+
let best = MIN_ACCENT_CONTRAST
|
|
90
|
+
for (let i = 0; i < candidates.length && i < THEME_MAX_BUTTONS; i++) {
|
|
91
|
+
const el = candidates[i]
|
|
92
|
+
if (!visible(el)) continue
|
|
93
|
+
const s = getStyle(el)
|
|
94
|
+
const accent = cleanColor(s.getPropertyValue('background-color'))
|
|
95
|
+
if (!accent || accent === theme.bg) continue
|
|
96
|
+
const contrast = colorDistance(accent, theme.bg)
|
|
97
|
+
if (contrast <= best) continue
|
|
98
|
+
best = contrast
|
|
99
|
+
put('accent', accent)
|
|
100
|
+
put('accentFg', cleanColor(s.getPropertyValue('color')))
|
|
101
|
+
button = el
|
|
102
|
+
}
|
|
103
|
+
const radiusSource = anchor && anchor.tagName.toLowerCase() === 'button' ? anchor : button
|
|
104
|
+
if (radiusSource) put('radius', cleanPx(getStyle(radiusSource).getPropertyValue('border-top-left-radius'), 0, 24))
|
|
105
|
+
return Object.keys(theme).length > 0 ? theme : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function themeVars(theme: PageTheme | null | undefined): Array<[string, string]> {
|
|
109
|
+
const out: Array<[string, string]> = []
|
|
110
|
+
for (const key of THEME_KEYS) {
|
|
111
|
+
const value = theme?.[key]
|
|
112
|
+
if (value) out.push([themeVarName(key), value])
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function applyThemeVars(el: { style: { setProperty(name: string, value: string): void; removeProperty(name: string): void } }, theme: PageTheme | null | undefined): void {
|
|
118
|
+
for (const key of THEME_KEYS) el.style.removeProperty(themeVarName(key))
|
|
119
|
+
for (const [name, value] of themeVars(theme)) el.style.setProperty(name, value)
|
|
120
|
+
}
|
package/src/tip-css.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O card da dica e da pergunta, como o SDK desenha no produto do cliente.
|
|
3
|
+
* O painel da Holdy importa daqui pra prévia: a mesma folha, nunca uma cópia.
|
|
4
|
+
* As variáveis --holdy-* chegam do tema lido na página (theme.ts): fonte,
|
|
5
|
+
* cores, raio e cor do botão primário do produto do cliente. Sem tema, o
|
|
6
|
+
* card cai no visual próprio (escuro no claro, claro no escuro).
|
|
7
|
+
*/
|
|
8
|
+
export const TIP_CSS = `:host{all:initial;--h-bg:#111;--h-fg:#fff;--h-accent:#fff;--h-accent-fg:#111;--h-border:transparent}
|
|
9
|
+
@media (prefers-color-scheme:dark){:host{--h-bg:#fff;--h-fg:#111;--h-accent:#111;--h-accent-fg:#fff}}
|
|
10
|
+
.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}
|
|
11
|
+
.tip p{margin:0;flex:1}
|
|
12
|
+
.tip button{all:unset;cursor:pointer;opacity:.7;font-size:15px;line-height:1;color:inherit}
|
|
13
|
+
.tip button:hover,.tip button:focus-visible{opacity:1}
|
|
14
|
+
.ask{flex-direction:column;width:300px;max-width:calc(100vw - 16px);gap:8px}
|
|
15
|
+
.ask .head{display:flex;gap:10px;align-items:flex-start;width:100%}
|
|
16
|
+
.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}
|
|
17
|
+
.ask textarea:focus-visible{outline:2px solid color-mix(in srgb,var(--holdy-fg,var(--h-fg)) 45%,transparent)}
|
|
18
|
+
.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}
|
|
19
|
+
.ask .send[disabled]{opacity:.4;cursor:default}
|
|
20
|
+
.list{flex-direction:column;width:300px;max-width:calc(100vw - 16px);gap:8px}
|
|
21
|
+
.list .head{display:flex;gap:10px;align-items:flex-start;width:100%}
|
|
22
|
+
.list .head p{display:flex;justify-content:space-between;gap:10px;align-items:baseline}
|
|
23
|
+
.list .count{opacity:.7;font-size:12px;white-space:nowrap}
|
|
24
|
+
.list ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px;width:100%}
|
|
25
|
+
.list li{display:flex;gap:8px;align-items:flex-start}
|
|
26
|
+
.list li.done{opacity:.55;text-decoration:line-through}
|
|
27
|
+
.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}
|
|
28
|
+
.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))}
|
|
29
|
+
.list.folded ul{display:none}
|
|
30
|
+
.list.folded{width:auto}
|
|
31
|
+
.preview{flex:none;order:-1;width:100%;font-size:11px;opacity:.7}
|
|
32
|
+
.tip:has(.preview){flex-wrap:wrap;row-gap:4px}`
|
package/src/types.ts
CHANGED
|
@@ -35,6 +35,12 @@ export interface HoldyOptions {
|
|
|
35
35
|
* declarar a account COM traits.
|
|
36
36
|
*/
|
|
37
37
|
accountKey?: string
|
|
38
|
+
/**
|
|
39
|
+
* Versão do produto no ar (sha do commit, tag ou número do build). Vai em
|
|
40
|
+
* `context.release` de todo evento, pra Holdy dizer em que versão um erro
|
|
41
|
+
* apareceu.
|
|
42
|
+
*/
|
|
43
|
+
release?: string
|
|
38
44
|
/** Logs de debug no console. */
|
|
39
45
|
debug?: boolean
|
|
40
46
|
}
|
package/src/web-context.test.ts
CHANGED
|
@@ -69,3 +69,21 @@ describe('parseUserAgent', () => {
|
|
|
69
69
|
expect(parseUserAgent('')).toEqual({})
|
|
70
70
|
})
|
|
71
71
|
})
|
|
72
|
+
|
|
73
|
+
describe('clearSessionId', () => {
|
|
74
|
+
it('reset encerra a sessão: a próxima pessoa na mesma aba não herda a anterior', async () => {
|
|
75
|
+
const guardado = new Map<string, string>()
|
|
76
|
+
const janela = globalThis as unknown as { sessionStorage?: unknown }
|
|
77
|
+
janela.sessionStorage = {
|
|
78
|
+
getItem: (k: string) => guardado.get(k) ?? null,
|
|
79
|
+
setItem: (k: string, v: string) => void guardado.set(k, v),
|
|
80
|
+
removeItem: (k: string) => void guardado.delete(k),
|
|
81
|
+
}
|
|
82
|
+
const { clearSessionId, resolveSessionId } = await import('./web-context')
|
|
83
|
+
const primeira = resolveSessionId()
|
|
84
|
+
expect(resolveSessionId()).toBe(primeira)
|
|
85
|
+
clearSessionId()
|
|
86
|
+
expect(resolveSessionId()).not.toBe(primeira)
|
|
87
|
+
delete janela.sessionStorage
|
|
88
|
+
})
|
|
89
|
+
})
|
package/src/web-context.ts
CHANGED
|
@@ -148,6 +148,14 @@ function randomId(): string {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
export function clearSessionId(): void {
|
|
152
|
+
try {
|
|
153
|
+
sessionStorage.removeItem(SESSION_STORAGE_KEY)
|
|
154
|
+
} catch {
|
|
155
|
+
// storage indisponível (SSR/privacy mode) — ok
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
151
159
|
/** ID da sessão de browser · vive enquanto a aba viver. */
|
|
152
160
|
export function resolveSessionId(): string {
|
|
153
161
|
const existing = readStorage('session', SESSION_STORAGE_KEY)
|
package/src/web.ts
CHANGED
|
@@ -15,9 +15,9 @@ import { HoldyCore } from './core'
|
|
|
15
15
|
import { installAutoCapture, type NavigationKind } from './auto-capture'
|
|
16
16
|
import { installFrictionCapture, type FrictionCapture, type FrictionCaptureOptions } from './friction'
|
|
17
17
|
import { installSkeletonCapture, type SkeletonCapture } from './skeleton'
|
|
18
|
-
import { buildWebContext } from './web-context'
|
|
18
|
+
import { buildWebContext, clearSessionId } from './web-context'
|
|
19
19
|
import { installProductChanges, type ProductChangesHandle } from './changes'
|
|
20
|
-
import type { HoldyOptions, Properties } from './types'
|
|
20
|
+
import type { HoldyOptions, Properties, TrackOverrides } from './types'
|
|
21
21
|
|
|
22
22
|
const ANON_STORAGE_KEY = 'holdy_anon_id'
|
|
23
23
|
|
|
@@ -125,6 +125,11 @@ export class HoldyWeb extends HoldyCore {
|
|
|
125
125
|
this.changes?.onNavigate()
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
override track(name: string, properties?: Properties, overrides?: TrackOverrides): void {
|
|
129
|
+
super.track(name, properties, overrides)
|
|
130
|
+
if (!name.startsWith('$')) this.changes?.onEvent(name)
|
|
131
|
+
}
|
|
132
|
+
|
|
128
133
|
reset(): void {
|
|
129
134
|
this.userId = null
|
|
130
135
|
this.accountKey = null
|
|
@@ -133,6 +138,7 @@ export class HoldyWeb extends HoldyCore {
|
|
|
133
138
|
} catch {
|
|
134
139
|
// storage indisponível (SSR/privacy mode) — ok
|
|
135
140
|
}
|
|
141
|
+
clearSessionId()
|
|
136
142
|
this.anonymousId = resolveAnonymousId()
|
|
137
143
|
this.changes?.onNavigate()
|
|
138
144
|
}
|