# matinee
> Staged cursor performances for React. Version 0.1.2.
A programmable ghost cursor. You script it, it performs against your real
running app using real DOM events, and then it exports itself as a file you can
commit.
Package: matinee (npm)
Repository: https://github.com/benhowdle89/matinee
Demo: https://matinee.pages.dev
License: MIT
Peer dependency: react >=18
Runtime dependencies: none
## Install
```sh
npm install matinee
```
## Minimal complete example
This is a working file. Nothing is elided.
```tsx
import { useEffect } from 'react'
import { Stage, useCursor } from 'matinee'
import 'matinee/styles.css'
function Demo() {
const cursor = useCursor()
useEffect(() => {
void (async () => {
await cursor.moveTo('#search')
await cursor.type('#search', 'invoices from March')
await cursor.click('#submit')
await cursor.say('and there it is')
})()
}, [cursor])
return
}
export default function Root() {
return (
)
}
```
Two required pieces:
1. Wrap the app in ``. It renders children untouched and mounts one
fixed, pointer-events:none, aria-hidden overlay. It never affects layout.
2. Import `matinee/styles.css` once. Without it the cursor is invisible.
`useCursor()` must be called inside a ``. It throws otherwise.
## Core concepts
**The actor** is what performs. You reach it with `useCursor()`.
**Targets** are how you say where. A target is one of:
- a CSS selector string, for example `'#submit'` or `'.row:nth-child(2) button'`
- a DOM `Element`
- a React ref, meaning an object with a `.current` property
- a point, `{ x: number, y: number }`, in viewport coordinates
Targets are resolved **when the step runs, not when you write it**. A selector
that does not exist yet is fine as long as it exists by the time the queue
reaches it. If it still does not match, matinee logs a warning and the cursor
stays put rather than throwing.
**The queue.** Every method returns a promise that resolves when its motion
finishes, and queues behind whatever is already running. This means `await` is
optional and the order of your calls is always the order of execution. These
two are equivalent:
```ts
await cursor.moveTo('#a'); await cursor.click('#a')
cursor.moveTo('#a'); cursor.click('#a')
```
**Real events.** `click` dispatches genuine `pointerdown`, `mousedown`,
`pointerup`, `mouseup` and `click` events on the resolved element, so React
`onClick` handlers fire. `type` writes through the prototype value setter and
dispatches `input`, which is what makes React controlled inputs actually update.
**Scripts.** Every performance records itself as plain JSON while it runs.
`cursor.getScript()` returns it; `cursor.play(script)` replays it.
## Recipes
### Fill and submit a form
```ts
await cursor.click('#email')
await cursor.type('#email', 'ada@example.com')
await cursor.type('#password', 'hunter2')
await cursor.click('button[type="submit"]')
```
### Scroll to something below the fold first
```ts
await cursor.scrollTo('#pricing')
await cursor.hover('#pro-plan', 800)
await cursor.click('#upgrade')
```
### Narrate with a speech bubble
```ts
await cursor.say('first, search for the invoice')
await cursor.type('#search', 'INV-0042')
await cursor.say('and there it is', 2000)
```
### Export an animated SVG for a README
```ts
const svg = cursor.toSvg({
background: '#faf8f4',
backdrop: '',
width: 720,
})
// write svg to a .svg file, commit it, then in markdown:
//
```
### Record the tab to WebM
```tsx
const recorder = useRecorder()
// recorder.start() triggers a browser permission prompt. This is unavoidable.
// recorder.stop() then fills recorder.blob and recorder.url.
// recorder.download('demo.webm') saves it.
```
### Replay a saved performance
```ts
const script = cursor.getScript()
localStorage.setItem('demo', JSON.stringify(script))
// later, possibly in another session
import { validateScript } from 'matinee'
await cursor.play(validateScript(JSON.parse(localStorage.getItem('demo'))))
```
## Behaviours that surprise people
- **`toSvg()` exports the cursor, never your page.** matinee does not
rasterise the DOM. With no options you get the cursor, nameplate and click
ripples on a transparent background, intended to be laid over a screenshot
you already have. To produce a standalone clip, pass `background` and a
`backdrop` of your own raw SVG markup.
- **A performance containing `scrollTo` cannot be exported coherently.**
Recorded points are viewport coordinates and the export has no concept of a
scroll offset, so points either side of a scroll do not relate to each other.
Keep exportable takes scroll-free.
- **`say()` is not rendered in SVG exports.** It shows live but the exporter
treats it as a pause.
- **The exported SVG contains no `