playwriter 0.0.105 → 0.2.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 (79) hide show
  1. package/dist/bippy.js +5 -5
  2. package/dist/cdp-relay.d.ts.map +1 -1
  3. package/dist/cdp-relay.js +17 -5
  4. package/dist/cdp-relay.js.map +1 -1
  5. package/dist/cli-help.test.d.ts +2 -0
  6. package/dist/cli-help.test.d.ts.map +1 -0
  7. package/dist/cli-help.test.js +53 -0
  8. package/dist/cli-help.test.js.map +1 -0
  9. package/dist/cli.js +74 -25
  10. package/dist/cli.js.map +1 -1
  11. package/dist/executor.d.ts +1 -0
  12. package/dist/executor.d.ts.map +1 -1
  13. package/dist/executor.js +55 -12
  14. package/dist/executor.js.map +1 -1
  15. package/dist/extension/background.js +675 -27
  16. package/dist/extension/manifest.json +1 -1
  17. package/dist/ghost-cursor-client.js +170 -83
  18. package/dist/{recording-ghost-cursor.d.ts → ghost-cursor-controller.d.ts} +15 -10
  19. package/dist/ghost-cursor-controller.d.ts.map +1 -0
  20. package/dist/ghost-cursor-controller.js +98 -0
  21. package/dist/ghost-cursor-controller.js.map +1 -0
  22. package/dist/ghost-cursor.d.ts.map +1 -1
  23. package/dist/ghost-cursor.js +42 -26
  24. package/dist/ghost-cursor.js.map +1 -1
  25. package/dist/mcp.d.ts.map +1 -1
  26. package/dist/mcp.js +6 -1
  27. package/dist/mcp.js.map +1 -1
  28. package/dist/on-mouse-action.test.js +25 -0
  29. package/dist/on-mouse-action.test.js.map +1 -1
  30. package/dist/performance-examples.d.ts +5 -0
  31. package/dist/performance-examples.d.ts.map +1 -0
  32. package/dist/performance-examples.js +112 -0
  33. package/dist/performance-examples.js.map +1 -0
  34. package/dist/performance-profiling.md +417 -0
  35. package/dist/prompt.md +22 -8
  36. package/dist/react-source.d.ts +44 -0
  37. package/dist/react-source.d.ts.map +1 -1
  38. package/dist/react-source.js +207 -20
  39. package/dist/react-source.js.map +1 -1
  40. package/dist/readability.js +1 -1
  41. package/dist/relay-core.test.d.ts.map +1 -1
  42. package/dist/relay-core.test.js +101 -1
  43. package/dist/relay-core.test.js.map +1 -1
  44. package/dist/relay-session.test.js +34 -6
  45. package/dist/relay-session.test.js.map +1 -1
  46. package/dist/screen-recording.d.ts +2 -2
  47. package/dist/screen-recording.d.ts.map +1 -1
  48. package/dist/screen-recording.js +19 -7
  49. package/dist/screen-recording.js.map +1 -1
  50. package/dist/selector-generator.js +1 -1
  51. package/package.json +7 -7
  52. package/src/aria-snapshots/github-interactive.txt +5 -3
  53. package/src/aria-snapshots/github-raw.txt +8 -5
  54. package/src/aria-snapshots/hackernews-interactive.txt +241 -238
  55. package/src/aria-snapshots/hackernews-raw.txt +269 -265
  56. package/src/aria-snapshots/prosemirror-interactive.txt +3 -1
  57. package/src/aria-snapshots/prosemirror-raw.txt +4 -1
  58. package/src/assets/aria-labels-hacker-news.png +0 -0
  59. package/src/assets/aria-labels-old-reddit.png +0 -0
  60. package/src/cdp-relay.ts +17 -5
  61. package/src/cli-help.test.ts +63 -0
  62. package/src/cli.ts +80 -28
  63. package/src/executor.ts +65 -15
  64. package/src/ghost-cursor-client.ts +221 -96
  65. package/src/{recording-ghost-cursor.ts → ghost-cursor-controller.ts} +50 -34
  66. package/src/ghost-cursor.ts +54 -41
  67. package/src/mcp.ts +6 -1
  68. package/src/on-mouse-action.test.ts +30 -0
  69. package/src/performance-examples.ts +186 -0
  70. package/src/react-source.ts +310 -24
  71. package/src/relay-core.test.ts +117 -0
  72. package/src/relay-session.test.ts +36 -10
  73. package/src/screen-recording.ts +23 -10
  74. package/src/skill.md +33 -9
  75. package/src/snapshots/shadcn-ui-accessibility-full.md +6 -3
  76. package/src/snapshots/shadcn-ui-accessibility-interactive.md +2 -0
  77. package/dist/recording-ghost-cursor.d.ts.map +0 -1
  78. package/dist/recording-ghost-cursor.js +0 -79
  79. package/dist/recording-ghost-cursor.js.map +0 -1
@@ -1,10 +1,23 @@
1
1
  /**
2
- * Browser-side ghost cursor renderer.
3
- * Injected into the page to visualize automated mouse actions with smooth easing.
2
+ * Browser-side ghost cursor renderer, injected into every Playwriter-attached tab.
3
+ * Auto-enables on load (top frame only). Idles out after 5s of no activity.
4
+ *
5
+ * Two-element DOM structure so move and press have independent CSS transitions:
6
+ * outer (#__playwriter_ghost_cursor__) → translate3d, move easing/duration
7
+ * inner (first child) → scale + opacity, press easing/duration
4
8
  */
5
9
 
6
10
  import { SCREENSTUDIO_POINTER_MACOS_TAHOE_DATA_URL } from './assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js'
7
11
 
12
+ // Top-frame only — skip iframes. try/catch for sandboxed iframes that throw.
13
+ const isTopFrame = (() => {
14
+ try {
15
+ return window === window.top
16
+ } catch {
17
+ return false
18
+ }
19
+ })()
20
+
8
21
  type GhostCursorActionType = 'move' | 'down' | 'up' | 'wheel'
9
22
  type GhostCursorButton = 'left' | 'right' | 'middle' | 'none'
10
23
  type GhostCursorStyle = 'minimal' | 'dot' | 'screenstudio'
@@ -39,13 +52,15 @@ interface GhostCursorRuntimeOptions {
39
52
  }
40
53
 
41
54
  interface GhostCursorRuntimeState {
42
- cursorElement: ReturnType<typeof createCursorElement> | null
55
+ outerElement: HTMLDivElement | null
56
+ innerElement: HTMLDivElement | null
43
57
  options: GhostCursorRuntimeOptions
44
58
  x: number
45
59
  y: number
46
60
  scale: number
47
61
  hasPosition: boolean
48
62
  enabled: boolean
63
+ idleHidden: boolean
49
64
  }
50
65
 
51
66
  interface GhostCursorApi {
@@ -66,27 +81,42 @@ const SCREENSTUDIO_HOTSPOT_Y_RATIO = 0.06
66
81
  const MINIMAL_TRIANGLE_HOTSPOT_X_RATIO = 0.07
67
82
  const MINIMAL_TRIANGLE_HOTSPOT_Y_RATIO = 0.06
68
83
 
84
+ // Animation curves from Emil Kowalski's guidelines (https://animations.dev):
85
+ // moves use ease-in-out (accel/decel), presses use strong ease-out (100-160ms).
86
+ const MOVE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)' // easeInOutCubic
87
+ const PRESS_EASING = 'cubic-bezier(0.23, 1, 0.32, 1)' // strong ease-out
88
+ const PRESS_DURATION_MS = 140
89
+
90
+ // Cursor fades out after 5s of no activity, wakes on next action.
91
+ const IDLE_HIDE_DELAY_MS = 5000
92
+ const IDLE_FADE_OUT_MS = 600
93
+
69
94
  const DEFAULT_OPTIONS: GhostCursorRuntimeOptions = {
70
95
  style: 'minimal',
71
96
  color: '#111827',
72
97
  size: 22,
73
98
  zIndex: 2147483647,
74
- easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
75
- minDurationMs: 24,
76
- maxDurationMs: 320,
77
- speedPxPerMs: 4,
99
+ easing: MOVE_EASING,
100
+ // Slow enough to track with the eye. Override per-call via ghostCursor.show().
101
+ minDurationMs: 220,
102
+ maxDurationMs: 1500,
103
+ speedPxPerMs: 1.2,
78
104
  }
79
105
 
80
106
  const runtime: GhostCursorRuntimeState = {
81
- cursorElement: null,
107
+ outerElement: null,
108
+ innerElement: null,
82
109
  options: DEFAULT_OPTIONS,
83
110
  x: 0,
84
111
  y: 0,
85
112
  scale: 1,
86
113
  hasPosition: false,
87
114
  enabled: false,
115
+ idleHidden: false,
88
116
  }
89
117
 
118
+ let idleHideTimer: ReturnType<typeof setTimeout> | null = null
119
+
90
120
  function clamp(options: { value: number; min: number; max: number }): number {
91
121
  const { value, min, max } = options
92
122
  return Math.min(max, Math.max(min, value))
@@ -159,13 +189,23 @@ function getBaseOpacity(): string {
159
189
  return '0.72'
160
190
  }
161
191
 
162
- function applyTransform(): void {
163
- if (!runtime.cursorElement) {
192
+ // Outer element: translate only (move timing).
193
+ function applyTranslate(): void {
194
+ if (!runtime.outerElement) {
164
195
  return
165
196
  }
166
197
 
167
198
  const hotspot = getHotspotOffsetPx()
168
- runtime.cursorElement.style.transform = `translate3d(${runtime.x - hotspot.x}px, ${runtime.y - hotspot.y}px, 0) scale(${runtime.scale})`
199
+ runtime.outerElement.style.transform = `translate3d(${runtime.x - hotspot.x}px, ${runtime.y - hotspot.y}px, 0)`
200
+ }
201
+
202
+ // Inner element: scale only (press timing).
203
+ function applyScale(): void {
204
+ if (!runtime.innerElement) {
205
+ return
206
+ }
207
+
208
+ runtime.innerElement.style.transform = `scale(${runtime.scale})`
169
209
  }
170
210
 
171
211
  function computeDurationMs(options: { targetX: number; targetY: number }): number {
@@ -185,93 +225,139 @@ function computeDurationMs(options: { targetX: number; targetY: number }): numbe
185
225
  })
186
226
  }
187
227
 
188
- function createCursorElement() {
189
- const element = document.createElement('div')
190
- element.id = CURSOR_ID
191
- element.setAttribute('aria-hidden', 'true')
192
- element.style.position = 'fixed'
193
- element.style.left = '0'
194
- element.style.top = '0'
195
- element.style.pointerEvents = 'none'
196
- element.style.zIndex = `${runtime.options.zIndex}`
197
- element.style.opacity = getBaseOpacity()
198
- element.style.transitionProperty = 'transform, opacity'
199
- element.style.transitionTimingFunction = runtime.options.easing
200
- element.style.transitionDuration = '0ms'
201
- element.style.willChange = 'transform'
202
-
203
- runtime.cursorElement = element
228
+ function createCursorElement(): HTMLDivElement {
229
+ const outer = document.createElement('div')
230
+ outer.id = CURSOR_ID
231
+ outer.setAttribute('aria-hidden', 'true')
232
+ outer.style.position = 'fixed'
233
+ outer.style.left = '0'
234
+ outer.style.top = '0'
235
+ outer.style.pointerEvents = 'none'
236
+ outer.style.zIndex = `${runtime.options.zIndex}`
237
+ outer.style.transitionProperty = 'transform'
238
+ outer.style.transitionTimingFunction = runtime.options.easing
239
+ outer.style.transitionDuration = '0ms'
240
+ outer.style.willChange = 'transform'
241
+
242
+ const inner = document.createElement('div')
243
+ inner.style.transitionProperty = 'transform, opacity'
244
+ inner.style.transitionTimingFunction = PRESS_EASING
245
+ inner.style.transitionDuration = `${PRESS_DURATION_MS}ms`
246
+ inner.style.opacity = getBaseOpacity()
247
+ outer.appendChild(inner)
248
+
249
+ runtime.outerElement = outer
250
+ runtime.innerElement = inner
204
251
  applyRuntimeVisualOptions()
205
252
 
206
- return element
253
+ return outer
207
254
  }
208
255
 
209
- function ensureCursorElement() {
210
- const existing = document.getElementById(CURSOR_ID)
256
+ function ensureCursorElement(): HTMLDivElement {
257
+ const existing = document.getElementById(CURSOR_ID) as HTMLDivElement | null
211
258
  if (existing) {
212
- runtime.cursorElement = existing
259
+ runtime.outerElement = existing
260
+ runtime.innerElement = (existing.firstElementChild as HTMLDivElement) || null
213
261
  return existing
214
262
  }
215
263
 
216
- const element = createCursorElement()
217
- runtime.cursorElement = element
264
+ const outer = createCursorElement()
218
265
  const root = document.documentElement || document.body
219
- root.appendChild(element)
220
- return element
266
+ root.appendChild(outer)
267
+ return outer
221
268
  }
222
269
 
223
270
  function applyRuntimeVisualOptions(): void {
224
- if (!runtime.cursorElement) {
271
+ if (!runtime.innerElement) {
225
272
  return
226
273
  }
227
274
 
228
275
  const dimensions = getCursorDimensions()
229
- runtime.cursorElement.style.width = `${dimensions.width}px`
230
- runtime.cursorElement.style.height = `${dimensions.height}px`
231
- runtime.cursorElement.style.zIndex = `${runtime.options.zIndex}`
232
- runtime.cursorElement.style.transitionTimingFunction = runtime.options.easing
276
+ runtime.innerElement.style.width = `${dimensions.width}px`
277
+ runtime.innerElement.style.height = `${dimensions.height}px`
278
+
279
+ if (runtime.outerElement) {
280
+ runtime.outerElement.style.zIndex = `${runtime.options.zIndex}`
281
+ runtime.outerElement.style.transitionTimingFunction = runtime.options.easing
282
+ }
283
+
284
+ // Scale around the hotspot so press doesn't shift the arrow tip.
285
+ const hotspot = getHotspotOffsetPx()
286
+ runtime.innerElement.style.transformOrigin = `${hotspot.x}px ${hotspot.y}px`
233
287
 
234
288
  if (runtime.options.style === 'screenstudio') {
235
- runtime.cursorElement.style.borderRadius = '0'
236
- runtime.cursorElement.style.border = 'none'
237
- runtime.cursorElement.style.backgroundColor = 'transparent'
238
- runtime.cursorElement.style.backgroundImage = `url("${SCREENSTUDIO_POINTER_MACOS_TAHOE_DATA_URL}")`
239
- runtime.cursorElement.style.backgroundRepeat = 'no-repeat'
240
- runtime.cursorElement.style.backgroundPosition = 'left top'
241
- runtime.cursorElement.style.backgroundSize = 'contain'
242
- runtime.cursorElement.style.backdropFilter = 'none'
243
- runtime.cursorElement.style.filter = 'none'
244
- runtime.cursorElement.style.boxShadow = 'none'
245
- runtime.cursorElement.style.opacity = getBaseOpacity()
289
+ runtime.innerElement.style.borderRadius = '0'
290
+ runtime.innerElement.style.border = 'none'
291
+ runtime.innerElement.style.backgroundColor = 'transparent'
292
+ runtime.innerElement.style.backgroundImage = `url("${SCREENSTUDIO_POINTER_MACOS_TAHOE_DATA_URL}")`
293
+ runtime.innerElement.style.backgroundRepeat = 'no-repeat'
294
+ runtime.innerElement.style.backgroundPosition = 'left top'
295
+ runtime.innerElement.style.backgroundSize = 'contain'
296
+ runtime.innerElement.style.backdropFilter = 'none'
297
+ runtime.innerElement.style.filter = 'none'
298
+ runtime.innerElement.style.boxShadow = 'none'
299
+ runtime.innerElement.style.opacity = getBaseOpacity()
246
300
  return
247
301
  }
248
302
 
249
303
  if (runtime.options.style === 'minimal') {
250
- // White fill with dark border stroke, like a standard macOS cursor
251
304
  const triangleSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="-1 -1 26 26"><path fill="white" stroke="${runtime.options.color}" stroke-width="1.5" stroke-linejoin="round" d="m23.284 19.124l-6.866-6.895a.4.4 0 0 1-.118-.296a.43.43 0 0 1 .163-.282l4.439-3.077a1.48 1.48 0 0 0 .621-1.48a1.48 1.48 0 0 0-1.036-1.198L1.623.302a1.14 1.14 0 0 0-1.11.282A1.13 1.13 0 0 0 .29 1.649L5.928 20.44a1.48 1.48 0 0 0 1.183 1.035a1.48 1.48 0 0 0 1.48-.621l3.078-4.44a.37.37 0 0 1 .31-.118a.43.43 0 0 1 .296.104l6.91 6.91a1.48 1.48 0 0 0 2.087 0l2.086-2.086a1.48 1.48 0 0 0-.074-2.101"/></svg>`
252
305
  const triangleDataUrl = `url("data:image/svg+xml,${encodeURIComponent(triangleSvg)}")`
253
- runtime.cursorElement.style.borderRadius = '0'
254
- runtime.cursorElement.style.border = 'none'
255
- runtime.cursorElement.style.backgroundColor = 'transparent'
256
- runtime.cursorElement.style.backgroundImage = triangleDataUrl
257
- runtime.cursorElement.style.backgroundRepeat = 'no-repeat'
258
- runtime.cursorElement.style.backgroundSize = 'contain'
259
- runtime.cursorElement.style.backgroundPosition = 'left top'
260
- runtime.cursorElement.style.backdropFilter = 'none'
261
- runtime.cursorElement.style.boxShadow = 'none'
262
- runtime.cursorElement.style.filter = 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4))'
263
- runtime.cursorElement.style.opacity = getBaseOpacity()
306
+ runtime.innerElement.style.borderRadius = '0'
307
+ runtime.innerElement.style.border = 'none'
308
+ runtime.innerElement.style.backgroundColor = 'transparent'
309
+ runtime.innerElement.style.backgroundImage = triangleDataUrl
310
+ runtime.innerElement.style.backgroundRepeat = 'no-repeat'
311
+ runtime.innerElement.style.backgroundSize = 'contain'
312
+ runtime.innerElement.style.backgroundPosition = 'left top'
313
+ runtime.innerElement.style.backdropFilter = 'none'
314
+ runtime.innerElement.style.boxShadow = 'none'
315
+ runtime.innerElement.style.filter = 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4))'
316
+ runtime.innerElement.style.opacity = getBaseOpacity()
264
317
  return
265
318
  }
266
319
 
267
- runtime.cursorElement.style.borderRadius = '999px'
268
- runtime.cursorElement.style.border = 'none'
269
- runtime.cursorElement.style.backgroundColor = runtime.options.color
270
- runtime.cursorElement.style.backgroundImage = 'none'
271
- runtime.cursorElement.style.backdropFilter = 'none'
272
- runtime.cursorElement.style.filter = 'none'
273
- runtime.cursorElement.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.18), inset 0 0 0 2px rgba(255, 255, 255, 0.55)'
274
- runtime.cursorElement.style.opacity = getBaseOpacity()
320
+ runtime.innerElement.style.borderRadius = '999px'
321
+ runtime.innerElement.style.border = 'none'
322
+ runtime.innerElement.style.backgroundColor = runtime.options.color
323
+ runtime.innerElement.style.backgroundImage = 'none'
324
+ runtime.innerElement.style.backdropFilter = 'none'
325
+ runtime.innerElement.style.filter = 'none'
326
+ runtime.innerElement.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.18), inset 0 0 0 2px rgba(255, 255, 255, 0.55)'
327
+ runtime.innerElement.style.opacity = getBaseOpacity()
328
+ }
329
+
330
+ function clearIdleHideTimer(): void {
331
+ if (idleHideTimer !== null) {
332
+ clearTimeout(idleHideTimer)
333
+ idleHideTimer = null
334
+ }
335
+ }
336
+
337
+ function scheduleIdleHide(): void {
338
+ clearIdleHideTimer()
339
+ idleHideTimer = setTimeout(() => {
340
+ idleHideTimer = null
341
+ if (!runtime.enabled || !runtime.innerElement) {
342
+ return
343
+ }
344
+ runtime.idleHidden = true
345
+ runtime.innerElement.style.transitionDuration = `${IDLE_FADE_OUT_MS}ms`
346
+ runtime.innerElement.style.transitionTimingFunction = PRESS_EASING
347
+ runtime.innerElement.style.opacity = '0'
348
+ }, IDLE_HIDE_DELAY_MS)
349
+ }
350
+
351
+ function wakeFromIdle(options: { x: number; y: number }): void {
352
+ // Teleport so moveCursor sees zero distance.
353
+ runtime.x = options.x
354
+ runtime.y = options.y
355
+ runtime.hasPosition = true
356
+ if (runtime.innerElement) {
357
+ runtime.innerElement.style.transitionDuration = `${PRESS_DURATION_MS}ms`
358
+ runtime.innerElement.style.transitionTimingFunction = PRESS_EASING
359
+ runtime.innerElement.style.opacity = getBaseOpacity()
360
+ }
275
361
  }
276
362
 
277
363
  function moveCursor(options: { x: number; y: number }): void {
@@ -279,31 +365,34 @@ function moveCursor(options: { x: number; y: number }): void {
279
365
  return
280
366
  }
281
367
 
282
- const element = ensureCursorElement()
368
+ ensureCursorElement()
283
369
  const durationMs = computeDurationMs({ targetX: options.x, targetY: options.y })
284
- element.style.transitionDuration = `${Math.round(durationMs)}ms`
370
+ if (runtime.outerElement) {
371
+ runtime.outerElement.style.transitionDuration = `${Math.round(durationMs)}ms`
372
+ runtime.outerElement.style.transitionTimingFunction = runtime.options.easing
373
+ }
285
374
 
286
375
  runtime.x = options.x
287
376
  runtime.y = options.y
288
377
  runtime.hasPosition = true
289
- applyTransform()
378
+ applyTranslate()
290
379
  }
291
380
 
292
381
  function setPressed(options: { pressed: boolean }): void {
293
- if (!runtime.enabled) {
382
+ if (!runtime.enabled || !runtime.innerElement) {
294
383
  return
295
384
  }
296
385
 
297
- const element = ensureCursorElement()
386
+ // Subtle press feedback (0.95). Dot style uses 0.92 — needs a bigger pulse.
298
387
  runtime.scale = options.pressed
299
- ? runtime.options.style === 'screenstudio'
300
- ? 0.94
301
- : runtime.options.style === 'minimal'
302
- ? 0.93
303
- : 0.82
388
+ ? runtime.options.style === 'dot'
389
+ ? 0.92
390
+ : 0.95
304
391
  : 1
305
- element.style.opacity = options.pressed ? '1' : getBaseOpacity()
306
- applyTransform()
392
+ runtime.innerElement.style.transitionDuration = `${PRESS_DURATION_MS}ms`
393
+ runtime.innerElement.style.transitionTimingFunction = PRESS_EASING
394
+ runtime.innerElement.style.opacity = options.pressed ? '1' : getBaseOpacity()
395
+ applyScale()
307
396
  }
308
397
 
309
398
  function enable(options?: GhostCursorClientOptions): void {
@@ -319,17 +408,27 @@ function enable(options?: GhostCursorClientOptions): void {
319
408
  runtime.hasPosition = true
320
409
  }
321
410
 
322
- applyTransform()
411
+ runtime.idleHidden = false
412
+ if (runtime.innerElement) {
413
+ runtime.innerElement.style.opacity = getBaseOpacity()
414
+ }
415
+
416
+ applyTranslate()
417
+ applyScale()
418
+ scheduleIdleHide()
323
419
  }
324
420
 
325
421
  function disable(): void {
326
422
  runtime.enabled = false
327
423
  runtime.scale = 1
328
424
  runtime.hasPosition = false
425
+ runtime.idleHidden = false
426
+ clearIdleHideTimer()
329
427
 
330
- if (runtime.cursorElement) {
331
- runtime.cursorElement.remove()
332
- runtime.cursorElement = null
428
+ if (runtime.outerElement) {
429
+ runtime.outerElement.remove()
430
+ runtime.outerElement = null
431
+ runtime.innerElement = null
333
432
  }
334
433
  }
335
434
 
@@ -338,21 +437,22 @@ function applyMouseAction(action: GhostCursorAction): void {
338
437
  return
339
438
  }
340
439
 
341
- if (action.type === 'move' || action.type === 'wheel') {
342
- moveCursor({ x: action.x, y: action.y })
343
- return
440
+ if (runtime.idleHidden) {
441
+ runtime.idleHidden = false
442
+ wakeFromIdle({ x: action.x, y: action.y })
344
443
  }
345
444
 
346
- if (action.type === 'down') {
445
+ if (action.type === 'move' || action.type === 'wheel') {
446
+ moveCursor({ x: action.x, y: action.y })
447
+ } else if (action.type === 'down') {
347
448
  moveCursor({ x: action.x, y: action.y })
348
449
  setPressed({ pressed: true })
349
- return
350
- }
351
-
352
- if (action.type === 'up') {
450
+ } else if (action.type === 'up') {
353
451
  moveCursor({ x: action.x, y: action.y })
354
452
  setPressed({ pressed: false })
355
453
  }
454
+
455
+ scheduleIdleHide()
356
456
  }
357
457
 
358
458
  const api: GhostCursorApi = {
@@ -364,6 +464,31 @@ const api: GhostCursorApi = {
364
464
  },
365
465
  }
366
466
 
367
- globalThis.__playwriterGhostCursor = api
467
+ if (isTopFrame) {
468
+ globalThis.__playwriterGhostCursor = api
469
+
470
+ // Auto-enable. Defer for early injection (addScriptToEvaluateOnNewDocument)
471
+ // when DOM isn't ready yet. After hard navigations the cursor re-centers
472
+ // until the next mouse action arrives.
473
+ try {
474
+ if (document.readyState === 'loading') {
475
+ document.addEventListener(
476
+ 'DOMContentLoaded',
477
+ () => {
478
+ try {
479
+ api.enable()
480
+ } catch {
481
+ // Non-fatal — DOM may be in an unexpected state.
482
+ }
483
+ },
484
+ { once: true },
485
+ )
486
+ } else {
487
+ api.enable()
488
+ }
489
+ } catch {
490
+ // Restricted contexts (chrome://, devtools://) — silently skip.
491
+ }
492
+ }
368
493
 
369
494
  export {}
@@ -1,6 +1,9 @@
1
1
  /**
2
- * Encapsulates ghost cursor lifecycle for recording sessions.
3
- * Keeps onMouseAction chaining/restoration isolated from executor logic.
2
+ * Always-on ghost cursor controller (Node side).
3
+ *
4
+ * Wires page.onMouseAction → applyGhostCursorMouseAction for every page.
5
+ * Chains with any pre-existing onMouseAction callback. Cursor-apply is
6
+ * fire-and-forget via a per-page queue so it does not block action completion.
4
7
  */
5
8
 
6
9
  import type { BrowserContext, Page } from '@xmorse/playwright-core'
@@ -11,7 +14,7 @@ import {
11
14
  type GhostCursorClientOptions,
12
15
  } from './ghost-cursor.js'
13
16
 
14
- interface RecordingGhostCursorLogger {
17
+ interface GhostCursorLogger {
15
18
  error: (...args: unknown[]) => void
16
19
  }
17
20
 
@@ -20,12 +23,13 @@ interface RecordingTargetOptions {
20
23
  sessionId?: string
21
24
  }
22
25
 
23
- export class RecordingGhostCursorController {
26
+ export class GhostCursorController {
24
27
  private readonly previousMouseActionByPage = new WeakMap<Page, Page['onMouseAction']>()
25
28
  private readonly cursorApplyQueueByPage = new WeakMap<Page, Promise<void>>()
26
- private readonly logger: RecordingGhostCursorLogger
29
+ private readonly attachedPages = new WeakSet<Page>()
30
+ private readonly logger: GhostCursorLogger
27
31
 
28
- constructor(options: { logger: RecordingGhostCursorLogger }) {
32
+ constructor(options: { logger: GhostCursorLogger }) {
29
33
  this.logger = options.logger
30
34
  }
31
35
 
@@ -53,61 +57,73 @@ export class RecordingGhostCursorController {
53
57
  return defaultPage
54
58
  }
55
59
 
56
- async enableForRecording(options: { page: Page }): Promise<void> {
60
+ /** Wire onMouseAction. Idempotent. */
61
+ attachToPage(options: { page: Page }): void {
57
62
  const { page } = options
58
63
 
59
- try {
60
- await enableGhostCursor({ page })
64
+ if (this.attachedPages.has(page)) {
65
+ return
66
+ }
67
+ this.attachedPages.add(page)
61
68
 
62
- if (!this.previousMouseActionByPage.has(page)) {
63
- this.previousMouseActionByPage.set(page, page.onMouseAction)
64
- }
69
+ if (!this.previousMouseActionByPage.has(page)) {
70
+ this.previousMouseActionByPage.set(page, page.onMouseAction)
71
+ }
72
+ const previousMouseAction = this.previousMouseActionByPage.get(page)
65
73
 
66
- const previousMouseAction = this.previousMouseActionByPage.get(page)
67
- page.onMouseAction = async (event) => {
74
+ page.onMouseAction = async (event) => {
75
+ // Ghost cursor must never crash the main Playwright action (click, move, etc).
76
+ // Wrap the entire cursor logic in try/catch so errors stay cosmetic.
77
+ try {
68
78
  const pendingCursorApply = this.cursorApplyQueueByPage.get(page) || Promise.resolve()
69
79
  const nextCursorApply = pendingCursorApply
70
80
  .then(async () => {
71
81
  await applyGhostCursorMouseAction({ page, event })
72
82
  })
73
83
  .catch((error) => {
84
+ if (page.isClosed()) {
85
+ return
86
+ }
74
87
  this.logger.error('[playwriter] Failed to apply ghost cursor action', error)
75
88
  })
76
89
  this.cursorApplyQueueByPage.set(page, nextCursorApply)
90
+ } catch (error) {
91
+ this.logger.error('[playwriter] Ghost cursor onMouseAction error (non-fatal)', error)
92
+ }
77
93
 
78
- if (!previousMouseAction) {
79
- return
80
- }
81
-
82
- await previousMouseAction(event)
94
+ if (!previousMouseAction) {
95
+ return
83
96
  }
84
- } catch (error) {
85
- page.onMouseAction = this.previousMouseActionByPage.get(page) ?? null
86
- this.previousMouseActionByPage.delete(page)
87
- this.logger.error('[playwriter] Failed to enable ghost cursor', error)
97
+ await previousMouseAction(event)
88
98
  }
89
99
  }
90
100
 
91
- async disableForRecording(options: { page: Page }): Promise<void> {
101
+ detachFromPage(options: { page: Page }): void {
92
102
  const { page } = options
103
+ if (!this.attachedPages.has(page)) {
104
+ return
105
+ }
106
+ this.attachedPages.delete(page)
93
107
  page.onMouseAction = this.previousMouseActionByPage.get(page) ?? null
94
108
  this.previousMouseActionByPage.delete(page)
95
109
  this.cursorApplyQueueByPage.delete(page)
96
-
97
- try {
98
- await disableGhostCursor({ page })
99
- } catch (error) {
100
- this.logger.error('[playwriter] Failed to disable ghost cursor', error)
101
- }
102
110
  }
103
111
 
104
112
  async show(options: { page: Page; cursorOptions?: GhostCursorClientOptions }): Promise<void> {
105
- const { page, cursorOptions } = options
106
- await enableGhostCursor({ page, cursorOptions })
113
+ try {
114
+ const { page, cursorOptions } = options
115
+ await enableGhostCursor({ page, cursorOptions })
116
+ } catch {
117
+ // Non-fatal — page may be closing or navigating.
118
+ }
107
119
  }
108
120
 
109
121
  async hide(options: { page: Page }): Promise<void> {
110
- const { page } = options
111
- await disableGhostCursor({ page })
122
+ try {
123
+ const { page } = options
124
+ await disableGhostCursor({ page })
125
+ } catch {
126
+ // Non-fatal — page may be closing or navigating.
127
+ }
112
128
  }
113
129
  }