@holdyapp/sdk 1.6.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +8 -1
  2. package/dist/changes.d.ts +16 -2
  3. package/dist/changes.d.ts.map +1 -1
  4. package/dist/core.d.ts +1 -0
  5. package/dist/core.d.ts.map +1 -1
  6. package/dist/friction.d.ts +2 -1
  7. package/dist/friction.d.ts.map +1 -1
  8. package/dist/holdy.js +25 -4
  9. package/dist/holdy.js.map +4 -4
  10. package/dist/index.cjs +420 -36
  11. package/dist/index.cjs.map +4 -4
  12. package/dist/index.mjs +420 -36
  13. package/dist/index.mjs.map +4 -4
  14. package/dist/server.cjs +3 -0
  15. package/dist/server.cjs.map +2 -2
  16. package/dist/server.mjs +3 -0
  17. package/dist/server.mjs.map +2 -2
  18. package/dist/skeleton.d.ts +3 -1
  19. package/dist/skeleton.d.ts.map +1 -1
  20. package/dist/theme.d.ts +43 -0
  21. package/dist/theme.d.ts.map +1 -0
  22. package/dist/tip-css.d.ts +9 -0
  23. package/dist/tip-css.d.ts.map +1 -0
  24. package/dist/types.d.ts +6 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/web-context.d.ts +1 -0
  27. package/dist/web-context.d.ts.map +1 -1
  28. package/dist/web.d.ts +2 -1
  29. package/dist/web.d.ts.map +1 -1
  30. package/dist/web.mjs +420 -36
  31. package/dist/web.mjs.map +4 -4
  32. package/package.json +1 -1
  33. package/src/changes.dom.test.ts +221 -0
  34. package/src/changes.test.ts +38 -0
  35. package/src/changes.ts +299 -26
  36. package/src/core.test.ts +15 -0
  37. package/src/core.ts +3 -0
  38. package/src/friction.dom.test.ts +54 -0
  39. package/src/friction.test.ts +43 -0
  40. package/src/friction.ts +44 -7
  41. package/src/skeleton.ts +12 -3
  42. package/src/theme.test.ts +118 -0
  43. package/src/theme.ts +120 -0
  44. package/src/tip-css.ts +32 -0
  45. package/src/types.ts +6 -0
  46. package/src/web-context.test.ts +18 -0
  47. package/src/web-context.ts +8 -0
  48. package/src/web.ts +8 -2
package/src/friction.ts CHANGED
@@ -79,7 +79,7 @@ export class NavLoopDetector {
79
79
  }
80
80
 
81
81
  export interface ElementLike {
82
- tagName: string
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.tagName.toLowerCase()
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.tagName.toLowerCase()
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.tagName.toLowerCase() }
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', { message: errorMessage(ev.reason).slice(0, 200), unhandled_rejection: true })
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.tagName.toLowerCase())) return
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.tagName.toLowerCase()
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(root: SkeletonRootLike, path: string, viewport: SkeletonViewport | null): PageSkeleton {
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 skeleton = collectSkeleton(document, path, viewport)
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
  }
@@ -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
+ })
@@ -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
  }