makethisbetter 1.0.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 (168) hide show
  1. package/LICENSE +62 -0
  2. package/README.md +113 -0
  3. package/dist/annotate/draw.d.ts +21 -0
  4. package/dist/annotate/draw.d.ts.map +1 -0
  5. package/dist/annotate/draw.test.d.ts +2 -0
  6. package/dist/annotate/draw.test.d.ts.map +1 -0
  7. package/dist/annotate/highlighter.d.ts +11 -0
  8. package/dist/annotate/highlighter.d.ts.map +1 -0
  9. package/dist/annotate/highlighter.test.d.ts +2 -0
  10. package/dist/annotate/highlighter.test.d.ts.map +1 -0
  11. package/dist/annotate/overlay.d.ts +17 -0
  12. package/dist/annotate/overlay.d.ts.map +1 -0
  13. package/dist/annotate/overlay.test.d.ts +2 -0
  14. package/dist/annotate/overlay.test.d.ts.map +1 -0
  15. package/dist/annotate/pin.d.ts +10 -0
  16. package/dist/annotate/pin.d.ts.map +1 -0
  17. package/dist/annotate/pin.test.d.ts +2 -0
  18. package/dist/annotate/pin.test.d.ts.map +1 -0
  19. package/dist/annotate/session.d.ts +20 -0
  20. package/dist/annotate/session.d.ts.map +1 -0
  21. package/dist/annotate/session.test.d.ts +2 -0
  22. package/dist/annotate/session.test.d.ts.map +1 -0
  23. package/dist/annotate/toolbar.d.ts +17 -0
  24. package/dist/annotate/toolbar.d.ts.map +1 -0
  25. package/dist/annotate/toolbar.test.d.ts +2 -0
  26. package/dist/annotate/toolbar.test.d.ts.map +1 -0
  27. package/dist/api/client.d.ts +10 -0
  28. package/dist/api/client.d.ts.map +1 -0
  29. package/dist/api/client.test.d.ts +2 -0
  30. package/dist/api/client.test.d.ts.map +1 -0
  31. package/dist/context/collector.d.ts +10 -0
  32. package/dist/context/collector.d.ts.map +1 -0
  33. package/dist/context/collector.test.d.ts +2 -0
  34. package/dist/context/collector.test.d.ts.map +1 -0
  35. package/dist/context/console.d.ts +10 -0
  36. package/dist/context/console.d.ts.map +1 -0
  37. package/dist/context/console.test.d.ts +2 -0
  38. package/dist/context/console.test.d.ts.map +1 -0
  39. package/dist/context/error-source.d.ts +21 -0
  40. package/dist/context/error-source.d.ts.map +1 -0
  41. package/dist/context/error-source.test.d.ts +2 -0
  42. package/dist/context/error-source.test.d.ts.map +1 -0
  43. package/dist/context/frustration.d.ts +27 -0
  44. package/dist/context/frustration.d.ts.map +1 -0
  45. package/dist/context/frustration.test.d.ts +2 -0
  46. package/dist/context/frustration.test.d.ts.map +1 -0
  47. package/dist/global.d.ts +3 -0
  48. package/dist/global.d.ts.map +1 -0
  49. package/dist/i18n/en.d.ts +47 -0
  50. package/dist/i18n/en.d.ts.map +1 -0
  51. package/dist/i18n/index.d.ts +4 -0
  52. package/dist/i18n/index.d.ts.map +1 -0
  53. package/dist/i18n/index.test.d.ts +2 -0
  54. package/dist/i18n/index.test.d.ts.map +1 -0
  55. package/dist/i18n/zh-CN.d.ts +47 -0
  56. package/dist/i18n/zh-CN.d.ts.map +1 -0
  57. package/dist/index.d.mts +8 -0
  58. package/dist/index.d.ts +8 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/makethisbetter.cjs.js +84 -0
  61. package/dist/makethisbetter.cjs.js.map +1 -0
  62. package/dist/makethisbetter.esm.js +1433 -0
  63. package/dist/makethisbetter.esm.js.map +1 -0
  64. package/dist/makethisbetter.js +84 -0
  65. package/dist/makethisbetter.js.map +1 -0
  66. package/dist/popup/comment.d.ts +31 -0
  67. package/dist/popup/comment.d.ts.map +1 -0
  68. package/dist/popup/comment.test.d.ts +2 -0
  69. package/dist/popup/comment.test.d.ts.map +1 -0
  70. package/dist/popup/success.d.ts +8 -0
  71. package/dist/popup/success.d.ts.map +1 -0
  72. package/dist/popup/success.test.d.ts +2 -0
  73. package/dist/popup/success.test.d.ts.map +1 -0
  74. package/dist/record/control-bar.d.ts +12 -0
  75. package/dist/record/control-bar.d.ts.map +1 -0
  76. package/dist/record/control-bar.test.d.ts +2 -0
  77. package/dist/record/control-bar.test.d.ts.map +1 -0
  78. package/dist/record/session.d.ts +26 -0
  79. package/dist/record/session.d.ts.map +1 -0
  80. package/dist/record/session.test.d.ts +2 -0
  81. package/dist/record/session.test.d.ts.map +1 -0
  82. package/dist/screenshot/capture.d.ts +3 -0
  83. package/dist/screenshot/capture.d.ts.map +1 -0
  84. package/dist/screenshot/capture.test.d.ts +2 -0
  85. package/dist/screenshot/capture.test.d.ts.map +1 -0
  86. package/dist/styles.d.ts +9 -0
  87. package/dist/styles.d.ts.map +1 -0
  88. package/dist/types.d.ts +45 -0
  89. package/dist/types.d.ts.map +1 -0
  90. package/dist/widget/controller.d.ts +37 -0
  91. package/dist/widget/controller.d.ts.map +1 -0
  92. package/dist/widget/controller.test.d.ts +2 -0
  93. package/dist/widget/controller.test.d.ts.map +1 -0
  94. package/dist/widget/frustration-prompt.d.ts +11 -0
  95. package/dist/widget/frustration-prompt.d.ts.map +1 -0
  96. package/dist/widget/frustration-prompt.test.d.ts +2 -0
  97. package/dist/widget/frustration-prompt.test.d.ts.map +1 -0
  98. package/dist/widget/payload.d.ts +5 -0
  99. package/dist/widget/payload.d.ts.map +1 -0
  100. package/dist/widget/payload.test.d.ts +2 -0
  101. package/dist/widget/payload.test.d.ts.map +1 -0
  102. package/dist/widget/pet.d.ts +12 -0
  103. package/dist/widget/pet.d.ts.map +1 -0
  104. package/dist/widget/pet.test.d.ts +2 -0
  105. package/dist/widget/pet.test.d.ts.map +1 -0
  106. package/dist/widget/shadow.d.ts +11 -0
  107. package/dist/widget/shadow.d.ts.map +1 -0
  108. package/dist/widget/shadow.test.d.ts +2 -0
  109. package/dist/widget/shadow.test.d.ts.map +1 -0
  110. package/dist/widget/tab.d.ts +10 -0
  111. package/dist/widget/tab.d.ts.map +1 -0
  112. package/dist/widget/tab.test.d.ts +2 -0
  113. package/dist/widget/tab.test.d.ts.map +1 -0
  114. package/package.json +64 -0
  115. package/src/annotate/draw.test.ts +168 -0
  116. package/src/annotate/draw.ts +108 -0
  117. package/src/annotate/highlighter.test.ts +47 -0
  118. package/src/annotate/highlighter.ts +84 -0
  119. package/src/annotate/overlay.test.ts +91 -0
  120. package/src/annotate/overlay.ts +44 -0
  121. package/src/annotate/pin.test.ts +87 -0
  122. package/src/annotate/pin.ts +28 -0
  123. package/src/annotate/session.test.ts +219 -0
  124. package/src/annotate/session.ts +104 -0
  125. package/src/annotate/toolbar.test.ts +132 -0
  126. package/src/annotate/toolbar.ts +81 -0
  127. package/src/api/client.test.ts +75 -0
  128. package/src/api/client.ts +77 -0
  129. package/src/context/collector.test.ts +14 -0
  130. package/src/context/collector.ts +41 -0
  131. package/src/context/console.test.ts +44 -0
  132. package/src/context/console.ts +34 -0
  133. package/src/context/error-source.test.ts +115 -0
  134. package/src/context/error-source.ts +62 -0
  135. package/src/context/frustration.test.ts +134 -0
  136. package/src/context/frustration.ts +145 -0
  137. package/src/global.ts +3 -0
  138. package/src/i18n/en.ts +47 -0
  139. package/src/i18n/index.test.ts +24 -0
  140. package/src/i18n/index.ts +16 -0
  141. package/src/i18n/zh-CN.ts +47 -0
  142. package/src/index.ts +32 -0
  143. package/src/popup/comment.test.ts +73 -0
  144. package/src/popup/comment.ts +177 -0
  145. package/src/popup/success.test.ts +75 -0
  146. package/src/popup/success.ts +33 -0
  147. package/src/record/control-bar.test.ts +110 -0
  148. package/src/record/control-bar.ts +46 -0
  149. package/src/record/session.test.ts +153 -0
  150. package/src/record/session.ts +139 -0
  151. package/src/screenshot/capture.test.ts +68 -0
  152. package/src/screenshot/capture.ts +96 -0
  153. package/src/styles/widget.css +847 -0
  154. package/src/styles.ts +9 -0
  155. package/src/types.ts +46 -0
  156. package/src/vite-env.d.ts +6 -0
  157. package/src/widget/controller.test.ts +62 -0
  158. package/src/widget/controller.ts +249 -0
  159. package/src/widget/frustration-prompt.test.ts +86 -0
  160. package/src/widget/frustration-prompt.ts +34 -0
  161. package/src/widget/payload.test.ts +167 -0
  162. package/src/widget/payload.ts +31 -0
  163. package/src/widget/pet.test.ts +63 -0
  164. package/src/widget/pet.ts +67 -0
  165. package/src/widget/shadow.test.ts +25 -0
  166. package/src/widget/shadow.ts +41 -0
  167. package/src/widget/tab.test.ts +92 -0
  168. package/src/widget/tab.ts +31 -0
@@ -0,0 +1,177 @@
1
+ import type { ShadowContainer } from '../widget/shadow'
2
+ import type { I18nMessages } from '../i18n'
3
+
4
+ interface PopupOptions {
5
+ targetName: string
6
+ x: number
7
+ y: number
8
+ onSubmit: (description: string) => void
9
+ onClose: () => void
10
+ messages: I18nMessages
11
+ }
12
+
13
+ export class CommentPopup {
14
+ private el: HTMLDivElement
15
+ private textarea!: HTMLTextAreaElement
16
+ private submitBtn!: HTMLButtonElement
17
+ private errorEl!: HTMLDivElement
18
+ private onSubmit: (description: string) => void
19
+ private onClose: () => void
20
+ private submitLabel: string
21
+ private loading = false
22
+
23
+ constructor(shadow: ShadowContainer, opts: PopupOptions) {
24
+ this.onSubmit = opts.onSubmit
25
+ this.onClose = opts.onClose
26
+ this.submitLabel = opts.messages.popup.submit
27
+
28
+ this.el = shadow.el<HTMLDivElement>('div', 'mtb-popup')
29
+ this.el.innerHTML = this.buildHTML(opts)
30
+ shadow.append(this.el)
31
+
32
+ this.textarea = this.el.querySelector('.mtb-textarea')!
33
+ this.submitBtn = this.el.querySelector('.mtb-submit-btn')!
34
+
35
+ this.positionPopup(opts.x, opts.y)
36
+ this.bindEvents(opts.messages)
37
+ this.textarea.focus()
38
+ }
39
+
40
+ private buildHTML(opts: PopupOptions): string {
41
+ const m = opts.messages
42
+ const quickOpts = m.popup.quickOptions
43
+ .map(
44
+ o => `
45
+ <button class="mtb-quick-option" data-quick="${escapeHtml(o.label)}">
46
+ <span class="mtb-quick-option-emoji">${o.emoji}</span>
47
+ <span>${escapeHtml(o.label)}</span>
48
+ </button>`,
49
+ )
50
+ .join('')
51
+
52
+ return `
53
+ <div class="mtb-popup-header">
54
+ <div class="mtb-popup-icon">
55
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
56
+ <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
57
+ </svg>
58
+ </div>
59
+ <span class="mtb-popup-title">${m.popup.about} "${escapeHtml(opts.targetName)}"</span>
60
+ <button class="mtb-popup-close" aria-label="Close">
61
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
62
+ <path d="M18 6L6 18M6 6l12 12"/>
63
+ </svg>
64
+ </button>
65
+ </div>
66
+ <div class="mtb-popup-body">
67
+ <div class="mtb-quick-options">
68
+ ${quickOpts}
69
+ </div>
70
+ <div class="mtb-textarea-wrap">
71
+ <textarea class="mtb-textarea" placeholder="${escapeHtml(m.popup.placeholder)}"></textarea>
72
+ </div>
73
+ <div class="mtb-popup-error" role="alert"></div>
74
+ <div class="mtb-popup-actions">
75
+ <button class="mtb-cancel-btn" type="button">${escapeHtml(m.popup.cancel)}</button>
76
+ <button class="mtb-submit-btn" type="button" disabled>${escapeHtml(m.popup.submit)}</button>
77
+ </div>
78
+ </div>
79
+ `
80
+ }
81
+
82
+ private positionPopup(x: number, y: number): void {
83
+ const popupW = 320
84
+ const popupH = 260
85
+ const vw = window.innerWidth
86
+ const vh = window.innerHeight
87
+ const margin = 12
88
+
89
+ let left = x + 12
90
+ let top = y + 12
91
+
92
+ if (left + popupW > vw - margin) {
93
+ left = x - popupW - 12
94
+ }
95
+ if (left < margin) left = margin
96
+
97
+ if (top + popupH > vh - margin) {
98
+ top = vh - popupH - margin
99
+ }
100
+ if (top < margin) top = margin
101
+
102
+ this.el.style.left = `${left}px`
103
+ this.el.style.top = `${top}px`
104
+ }
105
+
106
+ private bindEvents(messages: I18nMessages): void {
107
+ this.el.querySelector('.mtb-popup-close')!.addEventListener('click', () => this.onClose())
108
+ this.el.querySelector('.mtb-cancel-btn')!.addEventListener('click', () => this.onClose())
109
+ this.errorEl = this.el.querySelector('.mtb-popup-error')!
110
+
111
+ this.el.querySelectorAll('.mtb-quick-option').forEach(btn => {
112
+ btn.addEventListener('click', () => {
113
+ const label = (btn as HTMLElement).dataset['quick'] ?? ''
114
+ this.textarea.value = label
115
+ this.updateSubmitState()
116
+ this.textarea.focus()
117
+ })
118
+ })
119
+
120
+ this.textarea.addEventListener('input', () => this.updateSubmitState())
121
+
122
+ this.textarea.addEventListener('keydown', (e: KeyboardEvent) => {
123
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
124
+ e.preventDefault()
125
+ this.submit()
126
+ }
127
+ })
128
+
129
+ this.submitBtn.addEventListener('click', () => this.submit())
130
+ }
131
+
132
+ private updateSubmitState(): void {
133
+ const hasText = this.textarea.value.trim().length > 0
134
+ this.submitBtn.classList.toggle('ready', hasText && !this.loading)
135
+ this.submitBtn.disabled = !hasText || this.loading
136
+ this.errorEl.hidden = true
137
+ this.errorEl.textContent = ''
138
+ }
139
+
140
+ private submit(): void {
141
+ const text = this.textarea.value.trim()
142
+ if (!text || this.loading) return
143
+ this.onSubmit(text)
144
+ }
145
+
146
+ setLoading(loading: boolean): void {
147
+ this.loading = loading
148
+ if (loading) {
149
+ this.submitBtn.classList.add('loading')
150
+ this.submitBtn.innerHTML = `<span class="mtb-dot"></span><span class="mtb-dot"></span><span class="mtb-dot"></span>`
151
+ this.textarea.disabled = true
152
+ } else {
153
+ this.submitBtn.classList.remove('loading')
154
+ this.submitBtn.textContent = this.submitLabel
155
+ this.textarea.disabled = false
156
+ }
157
+ this.updateSubmitState()
158
+ }
159
+
160
+ setError(message: string): void {
161
+ this.errorEl.textContent = message
162
+ this.errorEl.hidden = false
163
+ }
164
+
165
+ destroy(): void {
166
+ this.el.remove()
167
+ }
168
+ }
169
+
170
+ function escapeHtml(text: string): string {
171
+ return text
172
+ .replace(/&/g, '&amp;')
173
+ .replace(/</g, '&lt;')
174
+ .replace(/>/g, '&gt;')
175
+ .replace(/"/g, '&quot;')
176
+ .replace(/'/g, '&#039;')
177
+ }
@@ -0,0 +1,75 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { SuccessCard } from './success'
3
+ import { ShadowContainer } from '../widget/shadow'
4
+ import { getMessages } from '../i18n'
5
+
6
+ function setupCard() {
7
+ const shadow = new ShadowContainer()
8
+ const onClose = vi.fn()
9
+ const messages = getMessages('en')
10
+ const card = new SuccessCard(shadow, messages, onClose)
11
+
12
+ return { shadow, card, onClose, messages }
13
+ }
14
+
15
+ describe('SuccessCard', () => {
16
+ beforeEach(() => {
17
+ vi.useFakeTimers()
18
+ })
19
+
20
+ afterEach(() => {
21
+ vi.useRealTimers()
22
+ })
23
+
24
+ it('renders title, message, and close button', () => {
25
+ const { shadow, card, messages } = setupCard()
26
+
27
+ expect(shadow.root.querySelector('.mtb-success-title')?.textContent).toBe(messages.success.title)
28
+ expect(shadow.root.querySelector('.mtb-success-msg')?.textContent).toBe(messages.success.message)
29
+ expect(shadow.root.querySelector('.mtb-close-link')?.textContent).toBe(messages.success.close)
30
+
31
+ card.destroy()
32
+ shadow.destroy()
33
+ })
34
+
35
+ it('renders checkmark icon', () => {
36
+ const { shadow, card } = setupCard()
37
+
38
+ const icon = shadow.root.querySelector('.mtb-success-icon svg')
39
+ expect(icon).toBeTruthy()
40
+
41
+ card.destroy()
42
+ shadow.destroy()
43
+ })
44
+
45
+ it('calls onClose when close button is clicked', () => {
46
+ const { shadow, card, onClose } = setupCard()
47
+
48
+ shadow.root.querySelector<HTMLButtonElement>('.mtb-close-link')!.click()
49
+ expect(onClose).toHaveBeenCalledOnce()
50
+
51
+ card.destroy()
52
+ shadow.destroy()
53
+ })
54
+
55
+ it('auto-closes after 5 seconds', () => {
56
+ const { shadow, card, onClose } = setupCard()
57
+
58
+ expect(onClose).not.toHaveBeenCalled()
59
+ vi.advanceTimersByTime(5000)
60
+ expect(onClose).toHaveBeenCalled()
61
+
62
+ card.destroy()
63
+ shadow.destroy()
64
+ })
65
+
66
+ it('destroy removes element from shadow root', () => {
67
+ const { shadow, card } = setupCard()
68
+
69
+ expect(shadow.root.querySelector('.mtb-success')).toBeTruthy()
70
+ card.destroy()
71
+ expect(shadow.root.querySelector('.mtb-success')).toBeNull()
72
+
73
+ shadow.destroy()
74
+ })
75
+ })
@@ -0,0 +1,33 @@
1
+ import type { ShadowContainer } from '../widget/shadow'
2
+ import type { I18nMessages } from '../i18n'
3
+ import { BRAND_COLOR } from '../styles'
4
+
5
+ const SUCCESS_AUTO_CLOSE_MS = 5000
6
+
7
+ export class SuccessCard {
8
+ private el: HTMLDivElement
9
+
10
+ constructor(shadow: ShadowContainer, messages: I18nMessages, onClose: () => void) {
11
+ this.el = shadow.el<HTMLDivElement>('div', 'mtb-success')
12
+ this.el.style.right = '20px'
13
+ this.el.style.bottom = '20px'
14
+ this.el.innerHTML = `
15
+ <div class="mtb-success-icon">
16
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="${BRAND_COLOR}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
17
+ <polyline points="20 6 9 17 4 12"/>
18
+ </svg>
19
+ </div>
20
+ <div class="mtb-success-title">${messages.success.title}</div>
21
+ <div class="mtb-success-msg">${messages.success.message}</div>
22
+ <button class="mtb-close-link">${messages.success.close}</button>
23
+ `
24
+ shadow.append(this.el)
25
+ this.el.querySelector('.mtb-close-link')!.addEventListener('click', onClose)
26
+
27
+ setTimeout(() => onClose(), SUCCESS_AUTO_CLOSE_MS)
28
+ }
29
+
30
+ destroy(): void {
31
+ this.el.remove()
32
+ }
33
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { RecordControlBar } from './control-bar'
3
+ import type { ShadowContainer } from '../widget/shadow'
4
+ import type { I18nMessages } from '../i18n'
5
+
6
+ function createMockShadow(): ShadowContainer & { appended: Node[] } {
7
+ const appended: Node[] = []
8
+ return {
9
+ appended,
10
+ el: <T extends HTMLElement>(tag: string, className?: string): T => {
11
+ const el = document.createElement(tag) as T
12
+ if (className) el.className = className
13
+ return el
14
+ },
15
+ append: (...nodes: Node[]) => {
16
+ nodes.forEach((n) => {
17
+ appended.push(n)
18
+ document.body.appendChild(n)
19
+ })
20
+ },
21
+ remove: vi.fn(),
22
+ root: document.createElement('div') as unknown as ShadowRoot,
23
+ destroy: vi.fn(),
24
+ } as unknown as ShadowContainer & { appended: Node[] }
25
+ }
26
+
27
+ function createMessages(): I18nMessages {
28
+ return {
29
+ record: {
30
+ stop: 'Stop',
31
+ timer_label: 'Recording',
32
+ max_reached: 'Maximum recording time reached',
33
+ },
34
+ } as unknown as I18nMessages
35
+ }
36
+
37
+ describe('RecordControlBar', () => {
38
+ let shadow: ReturnType<typeof createMockShadow>
39
+ let bar: RecordControlBar
40
+
41
+ beforeEach(() => {
42
+ vi.useFakeTimers()
43
+ shadow = createMockShadow()
44
+ })
45
+
46
+ afterEach(() => {
47
+ bar?.destroy()
48
+ document.body.innerHTML = ''
49
+ vi.useRealTimers()
50
+ })
51
+
52
+ it('constructor creates DOM with timer and stop button', () => {
53
+ bar = new RecordControlBar(shadow, createMessages(), () => 0, () => {})
54
+
55
+ expect(shadow.appended).toHaveLength(1)
56
+ const container = shadow.appended[0] as HTMLElement
57
+
58
+ expect(container.querySelector('.mtb-record-dot')).toBeTruthy()
59
+ expect(container.querySelector('.mtb-record-timer')).toBeTruthy()
60
+ expect(container.querySelector('.mtb-record-stop')).toBeTruthy()
61
+ expect(container.querySelector('.mtb-record-stop')!.textContent).toBe('Stop')
62
+ })
63
+
64
+ it('stop button fires the onStop callback', () => {
65
+ const onStop = vi.fn()
66
+ bar = new RecordControlBar(shadow, createMessages(), () => 0, onStop)
67
+
68
+ const stopBtn = (shadow.appended[0] as HTMLElement).querySelector('.mtb-record-stop')!
69
+ stopBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
70
+
71
+ expect(onStop).toHaveBeenCalledOnce()
72
+ })
73
+
74
+ it('timer updates display every second', () => {
75
+ let seconds = 0
76
+ bar = new RecordControlBar(shadow, createMessages(), () => seconds, () => {})
77
+
78
+ const timerEl = (shadow.appended[0] as HTMLElement).querySelector('.mtb-record-timer')!
79
+ expect(timerEl.textContent).toBe('00:00')
80
+
81
+ seconds = 5
82
+ vi.advanceTimersByTime(1000)
83
+ expect(timerEl.textContent).toBe('00:05')
84
+
85
+ seconds = 65
86
+ vi.advanceTimersByTime(1000)
87
+ expect(timerEl.textContent).toBe('01:05')
88
+ })
89
+
90
+ it('destroy removes DOM element and stops interval', () => {
91
+ let seconds = 0
92
+ bar = new RecordControlBar(shadow, createMessages(), () => seconds, () => {})
93
+
94
+ const container = shadow.appended[0] as HTMLElement
95
+ expect(document.body.contains(container)).toBe(true)
96
+
97
+ bar.destroy()
98
+ expect(document.body.contains(container)).toBe(false)
99
+
100
+ seconds = 99
101
+ vi.advanceTimersByTime(5000)
102
+ })
103
+
104
+ it('initial timer text is 00:00', () => {
105
+ bar = new RecordControlBar(shadow, createMessages(), () => 0, () => {})
106
+
107
+ const timerEl = (shadow.appended[0] as HTMLElement).querySelector('.mtb-record-timer')!
108
+ expect(timerEl.textContent).toBe('00:00')
109
+ })
110
+ })
@@ -0,0 +1,46 @@
1
+ import type { ShadowContainer } from '../widget/shadow'
2
+ import type { I18nMessages } from '../i18n'
3
+
4
+ export class RecordControlBar {
5
+ private el: HTMLDivElement
6
+ private timerEl!: HTMLSpanElement
7
+ private intervalId: ReturnType<typeof setInterval> | null = null
8
+ private getDuration: () => number
9
+
10
+ constructor(
11
+ shadow: ShadowContainer,
12
+ messages: I18nMessages,
13
+ getDuration: () => number,
14
+ onStop: () => void,
15
+ ) {
16
+ this.getDuration = getDuration
17
+
18
+ this.el = shadow.el<HTMLDivElement>('div', 'mtb-record-bar')
19
+ this.el.innerHTML = `
20
+ <span class="mtb-record-dot"></span>
21
+ <span class="mtb-record-timer">00:00</span>
22
+ <button class="mtb-record-stop" type="button">${messages.record.stop}</button>
23
+ `
24
+ shadow.append(this.el)
25
+
26
+ this.timerEl = this.el.querySelector('.mtb-record-timer')!
27
+ this.el.querySelector('.mtb-record-stop')!.addEventListener('click', onStop)
28
+
29
+ this.intervalId = setInterval(() => this.updateTimer(), 1000)
30
+ }
31
+
32
+ private updateTimer(): void {
33
+ const secs = this.getDuration()
34
+ const mm = String(Math.floor(secs / 60)).padStart(2, '0')
35
+ const ss = String(secs % 60).padStart(2, '0')
36
+ this.timerEl.textContent = `${mm}:${ss}`
37
+ }
38
+
39
+ destroy(): void {
40
+ if (this.intervalId) {
41
+ clearInterval(this.intervalId)
42
+ this.intervalId = null
43
+ }
44
+ this.el.remove()
45
+ }
46
+ }
@@ -0,0 +1,153 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { RecordSession, type RecordingResult } from './session'
3
+ import type { ShadowContainer } from '../widget/shadow'
4
+
5
+ const mockStopFn = vi.fn()
6
+ const mockAddCustomEvent = vi.fn()
7
+ const mockRecord = Object.assign(
8
+ vi.fn(({ emit }: { emit: (event: Record<string, unknown>) => void }) => {
9
+ emit({ type: 2, data: 'snapshot' })
10
+ return mockStopFn
11
+ }),
12
+ { addCustomEvent: mockAddCustomEvent },
13
+ )
14
+
15
+ vi.mock('rrweb', () => ({ record: mockRecord }))
16
+
17
+ function createMockShadow(): ShadowContainer {
18
+ const elements: HTMLElement[] = []
19
+ return {
20
+ el: <T extends HTMLElement>(tag: string, className?: string): T => {
21
+ const el = document.createElement(tag) as T
22
+ if (className) el.className = className
23
+ return el
24
+ },
25
+ append: (...nodes: Node[]) => {
26
+ nodes.forEach((n) => elements.push(n as HTMLElement))
27
+ },
28
+ remove: vi.fn(),
29
+ root: document.createElement('div') as unknown as ShadowRoot,
30
+ destroy: vi.fn(),
31
+ } as unknown as ShadowContainer
32
+ }
33
+
34
+ describe('RecordSession', () => {
35
+ let session: RecordSession
36
+ let shadow: ShadowContainer
37
+
38
+ beforeEach(() => {
39
+ vi.useFakeTimers()
40
+ vi.clearAllMocks()
41
+ shadow = createMockShadow()
42
+ })
43
+
44
+ afterEach(() => {
45
+ session?.destroy()
46
+ vi.useRealTimers()
47
+ })
48
+
49
+ it('start() initializes recording via rrweb', async () => {
50
+ session = new RecordSession(shadow)
51
+ await session.start()
52
+
53
+ expect(mockRecord).toHaveBeenCalledOnce()
54
+ expect(mockRecord).toHaveBeenCalledWith(
55
+ expect.objectContaining({ emit: expect.any(Function) }),
56
+ )
57
+ })
58
+
59
+ it('stop() returns captured events and duration', async () => {
60
+ session = new RecordSession(shadow)
61
+ await session.start()
62
+
63
+ vi.advanceTimersByTime(5000)
64
+
65
+ const result: RecordingResult = session.stop()
66
+
67
+ expect(result.events).toHaveLength(1)
68
+ expect(result.events[0]).toEqual({ type: 2, data: 'snapshot' })
69
+ expect(result.duration).toBe(5)
70
+ expect(mockStopFn).toHaveBeenCalledOnce()
71
+ })
72
+
73
+ it('getDuration() returns elapsed time in seconds', async () => {
74
+ session = new RecordSession(shadow)
75
+ await session.start()
76
+
77
+ expect(session.getDuration()).toBe(0)
78
+
79
+ vi.advanceTimersByTime(3000)
80
+ expect(session.getDuration()).toBe(3)
81
+
82
+ vi.advanceTimersByTime(7000)
83
+ expect(session.getDuration()).toBe(10)
84
+ })
85
+
86
+ it('getDuration() returns 0 before start', () => {
87
+ session = new RecordSession(shadow)
88
+ expect(session.getDuration()).toBe(0)
89
+ })
90
+
91
+ it('auto-fires onMaxReached at MAX_DURATION (60s)', async () => {
92
+ const onMaxReached = vi.fn()
93
+ session = new RecordSession(shadow, onMaxReached)
94
+ await session.start()
95
+
96
+ vi.advanceTimersByTime(59_999)
97
+ expect(onMaxReached).not.toHaveBeenCalled()
98
+
99
+ vi.advanceTimersByTime(1)
100
+ expect(onMaxReached).toHaveBeenCalledOnce()
101
+ })
102
+
103
+ it('stop() clears the max-duration timer', async () => {
104
+ const onMaxReached = vi.fn()
105
+ session = new RecordSession(shadow, onMaxReached)
106
+ await session.start()
107
+
108
+ session.stop()
109
+ vi.advanceTimersByTime(120_000)
110
+
111
+ expect(onMaxReached).not.toHaveBeenCalled()
112
+ })
113
+
114
+ it('click events create ripple elements and add custom rrweb events', async () => {
115
+ session = new RecordSession(shadow)
116
+ await session.start()
117
+
118
+ const clickEvent = new MouseEvent('click', {
119
+ clientX: 100,
120
+ clientY: 200,
121
+ bubbles: true,
122
+ })
123
+ document.dispatchEvent(clickEvent)
124
+
125
+ expect(mockAddCustomEvent).toHaveBeenCalledWith('mtb-click', {
126
+ x: 100,
127
+ y: 200,
128
+ target: expect.any(String),
129
+ })
130
+ })
131
+
132
+ it('stop() removes click handler', async () => {
133
+ session = new RecordSession(shadow)
134
+ await session.start()
135
+
136
+ session.stop()
137
+ vi.clearAllMocks()
138
+
139
+ document.dispatchEvent(new MouseEvent('click', { bubbles: true }))
140
+
141
+ expect(mockAddCustomEvent).not.toHaveBeenCalled()
142
+ })
143
+
144
+ it('destroy() clears all state', async () => {
145
+ session = new RecordSession(shadow)
146
+ await session.start()
147
+
148
+ session.destroy()
149
+
150
+ expect(mockStopFn).toHaveBeenCalled()
151
+ expect(session.getDuration()).toBe(0)
152
+ })
153
+ })