@holdyapp/sdk 1.5.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +9 -1
  2. package/dist/changes.d.ts +16 -2
  3. package/dist/changes.d.ts.map +1 -1
  4. package/dist/core.d.ts +1 -0
  5. package/dist/core.d.ts.map +1 -1
  6. package/dist/friction.d.ts +2 -1
  7. package/dist/friction.d.ts.map +1 -1
  8. package/dist/holdy.js +25 -4
  9. package/dist/holdy.js.map +4 -4
  10. package/dist/index.cjs +540 -33
  11. package/dist/index.cjs.map +4 -4
  12. package/dist/index.mjs +540 -33
  13. package/dist/index.mjs.map +4 -4
  14. package/dist/server.cjs +3 -0
  15. package/dist/server.cjs.map +2 -2
  16. package/dist/server.mjs +3 -0
  17. package/dist/server.mjs.map +2 -2
  18. package/dist/skeleton.d.ts +52 -0
  19. package/dist/skeleton.d.ts.map +1 -0
  20. package/dist/theme.d.ts +43 -0
  21. package/dist/theme.d.ts.map +1 -0
  22. package/dist/tip-css.d.ts +9 -0
  23. package/dist/tip-css.d.ts.map +1 -0
  24. package/dist/types.d.ts +6 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/web-context.d.ts +1 -0
  27. package/dist/web-context.d.ts.map +1 -1
  28. package/dist/web.d.ts +10 -1
  29. package/dist/web.d.ts.map +1 -1
  30. package/dist/web.mjs +540 -33
  31. package/dist/web.mjs.map +4 -4
  32. package/package.json +1 -1
  33. package/src/changes.dom.test.ts +221 -0
  34. package/src/changes.test.ts +38 -0
  35. package/src/changes.ts +299 -26
  36. package/src/core.test.ts +15 -0
  37. package/src/core.ts +3 -0
  38. package/src/friction.dom.test.ts +54 -0
  39. package/src/friction.test.ts +43 -0
  40. package/src/friction.ts +44 -7
  41. package/src/skeleton.test.ts +60 -0
  42. package/src/skeleton.ts +182 -0
  43. package/src/theme.test.ts +118 -0
  44. package/src/theme.ts +120 -0
  45. package/src/tip-css.ts +32 -0
  46. package/src/types.ts +6 -0
  47. package/src/web-context.test.ts +18 -0
  48. package/src/web-context.ts +8 -0
  49. package/src/web.ts +28 -3
package/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
  }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { collectSkeleton, describeSkeletonElement, fnv1a, SKELETON_MAX_ELEMENTS, type SkeletonElementLike } from './skeleton'
3
+
4
+ function el(tag: string, attrs: Record<string, string> = {}, rect = { x: 10, y: 20, width: 100, height: 40 }): SkeletonElementLike {
5
+ return {
6
+ tagName: tag.toUpperCase(),
7
+ getAttribute: (name) => attrs[name] ?? null,
8
+ getBoundingClientRect: () => rect,
9
+ }
10
+ }
11
+
12
+ const viewport = { width: 1000, height: 2000, scrollY: 0 }
13
+
14
+ describe('fnv1a', () => {
15
+ it('é estável e conhecido', () => {
16
+ expect(fnv1a('')).toBe('811c9dc5')
17
+ expect(fnv1a('a')).toBe('e40c292c')
18
+ expect(fnv1a('holdy')).toBe(fnv1a('holdy'))
19
+ })
20
+ })
21
+
22
+ describe('describeSkeletonElement', () => {
23
+ it('guarda tag, role, data-testid, name, aria-label e tipo, nunca texto nem valor', () => {
24
+ const out = describeSkeletonElement(
25
+ el('input', { type: 'Email', name: 'email', 'aria-label': ' Seu e-mail ', value: 'segredo@x.com', placeholder: 'Digite' }),
26
+ viewport,
27
+ )
28
+ expect(out).toEqual({ t: 'input', n: 'email', l: 'Seu e-mail', k: 'email', b: [1, 1, 10, 2] })
29
+ expect(JSON.stringify(out)).not.toContain('segredo')
30
+ })
31
+
32
+ it('sem viewport não há geometria', () => {
33
+ expect(describeSkeletonElement(el('button', { 'data-testid': 'continue' }), null)).toEqual({ t: 'button', id: 'continue' })
34
+ })
35
+ })
36
+
37
+ describe('collectSkeleton', () => {
38
+ const root = (items: SkeletonElementLike[]) => ({ querySelectorAll: () => items })
39
+
40
+ it('o hash ignora a geometria e muda com a estrutura', () => {
41
+ const a = collectSkeleton(root([el('button', { 'data-testid': 'go' }, { x: 0, y: 0, width: 50, height: 20 })]), '/x', viewport)
42
+ const b = collectSkeleton(root([el('button', { 'data-testid': 'go' }, { x: 300, y: 900, width: 80, height: 30 })]), '/x', viewport)
43
+ const c = collectSkeleton(root([el('button', { 'data-testid': 'stop' })]), '/x', viewport)
44
+ expect(a.hash).toBe(b.hash)
45
+ expect(a.hash).not.toBe(c.hash)
46
+ expect(a.elements[0].b).toEqual([0, 0, 5, 1])
47
+ })
48
+
49
+ it('deixa fora input hidden e elemento sem tamanho, e conta o total antes do teto', () => {
50
+ const many = Array.from({ length: SKELETON_MAX_ELEMENTS + 10 }, (_, i) => el('a', { href: `/p/${i}` }))
51
+ const out = collectSkeleton(
52
+ root([el('input', { type: 'hidden', name: 'csrf' }), el('button', {}, { x: 0, y: 0, width: 0, height: 0 }), ...many]),
53
+ '/lista',
54
+ viewport,
55
+ )
56
+ expect(out.count).toBe(SKELETON_MAX_ELEMENTS + 10)
57
+ expect(out.elements).toHaveLength(SKELETON_MAX_ELEMENTS)
58
+ expect(out.elements.every((e) => e.t === 'a')).toBe(true)
59
+ })
60
+ })
@@ -0,0 +1,182 @@
1
+ import { readTheme, type PageTheme } from './theme'
2
+
3
+ export const SKELETON_SELECTOR =
4
+ 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [data-testid]'
5
+ export const SKELETON_MAX_ELEMENTS = 80
6
+ export const SKELETON_DELAY_MS = 1500
7
+ const SENT_STORAGE_KEY = 'holdy_skeletons'
8
+ const SENT_STORAGE_MAX = 200
9
+
10
+ export interface SkeletonRect {
11
+ x: number
12
+ y: number
13
+ width: number
14
+ height: number
15
+ }
16
+
17
+ export interface SkeletonElementLike {
18
+ tagName: string
19
+ getAttribute(name: string): string | null
20
+ getBoundingClientRect?(): SkeletonRect
21
+ }
22
+
23
+ export interface SkeletonRootLike {
24
+ querySelectorAll(selector: string): ArrayLike<SkeletonElementLike>
25
+ }
26
+
27
+ export interface SkeletonViewport {
28
+ width: number
29
+ height: number
30
+ scrollY: number
31
+ }
32
+
33
+ export interface SkeletonElement {
34
+ t: string
35
+ r?: string
36
+ id?: string
37
+ n?: string
38
+ l?: string
39
+ k?: string
40
+ b?: [number, number, number, number]
41
+ }
42
+
43
+ export interface PageSkeleton {
44
+ path: string
45
+ hash: string
46
+ count: number
47
+ elements: SkeletonElement[]
48
+ theme?: PageTheme
49
+ }
50
+
51
+ export function fnv1a(input: string): string {
52
+ let hash = 0x811c9dc5
53
+ for (let i = 0; i < input.length; i++) {
54
+ hash ^= input.charCodeAt(i)
55
+ hash = Math.imul(hash, 0x01000193) >>> 0
56
+ }
57
+ return hash.toString(16).padStart(8, '0')
58
+ }
59
+
60
+ function attr(el: SkeletonElementLike, name: string, max: number): string | undefined {
61
+ const value = el.getAttribute(name)
62
+ if (!value) return undefined
63
+ const trimmed = value.replace(/\s+/g, ' ').trim().slice(0, max)
64
+ return trimmed || undefined
65
+ }
66
+
67
+ function isVisible(el: SkeletonElementLike): boolean {
68
+ if (!el.getBoundingClientRect) return true
69
+ const rect = el.getBoundingClientRect()
70
+ return rect.width > 0 && rect.height > 0
71
+ }
72
+
73
+ function relativeBox(rect: SkeletonRect, viewport: SkeletonViewport): [number, number, number, number] {
74
+ const pct = (value: number, base: number) => (base > 0 ? Math.max(0, Math.min(100, Math.round((value / base) * 100))) : 0)
75
+ return [pct(rect.x, viewport.width), pct(rect.y + viewport.scrollY, viewport.height), pct(rect.width, viewport.width), pct(rect.height, viewport.height)]
76
+ }
77
+
78
+ export function describeSkeletonElement(el: SkeletonElementLike, viewport: SkeletonViewport | null): SkeletonElement {
79
+ const out: SkeletonElement = { t: el.tagName.toLowerCase() }
80
+ const role = attr(el, 'role', 40)
81
+ if (role) out.r = role
82
+ const testid = attr(el, 'data-testid', 80)
83
+ if (testid) out.id = testid
84
+ const name = attr(el, 'name', 80)
85
+ if (name) out.n = name
86
+ const label = attr(el, 'aria-label', 80)
87
+ if (label) out.l = label
88
+ if (out.t === 'input' || out.t === 'button') {
89
+ const type = attr(el, 'type', 20)
90
+ if (type) out.k = type.toLowerCase()
91
+ }
92
+ if (viewport && el.getBoundingClientRect) out.b = relativeBox(el.getBoundingClientRect(), viewport)
93
+ return out
94
+ }
95
+
96
+ export function collectSkeleton(
97
+ root: SkeletonRootLike,
98
+ path: string,
99
+ viewport: SkeletonViewport | null,
100
+ theme: PageTheme | null = null,
101
+ ): PageSkeleton {
102
+ const found = root.querySelectorAll(SKELETON_SELECTOR)
103
+ const visible: SkeletonElementLike[] = []
104
+ for (let i = 0; i < found.length; i++) {
105
+ const el = found[i]
106
+ if (el.tagName.toLowerCase() === 'input' && (el.getAttribute('type') ?? '').toLowerCase() === 'hidden') continue
107
+ if (!isVisible(el)) continue
108
+ visible.push(el)
109
+ }
110
+ const elements = visible.slice(0, SKELETON_MAX_ELEMENTS).map((el) => describeSkeletonElement(el, viewport))
111
+ const structural = elements.map(({ b: _b, ...rest }) => rest)
112
+ return { path, hash: fnv1a(JSON.stringify(structural)), count: visible.length, elements, ...(theme ? { theme } : {}) }
113
+ }
114
+
115
+ export interface SkeletonCaptureOptions {
116
+ onSkeleton: (skeleton: PageSkeleton) => void
117
+ delayMs?: number
118
+ }
119
+
120
+ export interface SkeletonCapture {
121
+ onNavigate: (path: string) => void
122
+ teardown: () => void
123
+ }
124
+
125
+ function loadSent(): Set<string> {
126
+ try {
127
+ const raw = sessionStorage.getItem(SENT_STORAGE_KEY)
128
+ const list = raw ? (JSON.parse(raw) as unknown) : []
129
+ return new Set(Array.isArray(list) ? list.filter((x): x is string => typeof x === 'string') : [])
130
+ } catch {
131
+ return new Set()
132
+ }
133
+ }
134
+
135
+ function saveSent(sent: Set<string>): void {
136
+ try {
137
+ sessionStorage.setItem(SENT_STORAGE_KEY, JSON.stringify([...sent].slice(-SENT_STORAGE_MAX)))
138
+ } catch {
139
+ return
140
+ }
141
+ }
142
+
143
+ export function installSkeletonCapture(opts: SkeletonCaptureOptions): SkeletonCapture {
144
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
145
+ return { onNavigate: () => undefined, teardown: () => undefined }
146
+ }
147
+ const delay = opts.delayMs ?? SKELETON_DELAY_MS
148
+ const sent = loadSent()
149
+ let pending: ReturnType<typeof setTimeout> | null = null
150
+
151
+ const capture = (path: string): void => {
152
+ pending = null
153
+ if (location.pathname !== path) return
154
+ try {
155
+ const viewport: SkeletonViewport = {
156
+ width: window.innerWidth,
157
+ height: Math.max(document.documentElement.scrollHeight, window.innerHeight),
158
+ scrollY: window.scrollY,
159
+ }
160
+ const theme = readTheme(document, (el) => window.getComputedStyle(el as Element))
161
+ const skeleton = collectSkeleton(document, path, viewport, theme)
162
+ const key = `${path}:${skeleton.hash}`
163
+ if (sent.has(key)) return
164
+ sent.add(key)
165
+ saveSent(sent)
166
+ opts.onSkeleton(skeleton)
167
+ } catch {
168
+ return
169
+ }
170
+ }
171
+
172
+ return {
173
+ onNavigate: (path: string) => {
174
+ if (pending) clearTimeout(pending)
175
+ pending = setTimeout(() => capture(path), delay)
176
+ },
177
+ teardown: () => {
178
+ if (pending) clearTimeout(pending)
179
+ pending = null
180
+ },
181
+ }
182
+ }
@@ -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)