@matte97p/demowright 0.1.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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/bin/cli.js +103 -0
- package/examples/geosuite-howitworks.config.js +127 -0
- package/examples/geosuite-workspace-audit.config.js +83 -0
- package/examples/local-demo.config.js +36 -0
- package/examples/site/index.html +141 -0
- package/package.json +67 -0
- package/src/index.js +41 -0
- package/src/overlay.js +262 -0
- package/src/render.js +137 -0
- package/src/runner.js +262 -0
- package/src/scaffold.js +46 -0
- package/src/steps.js +150 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/), and this project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-06-22
|
|
8
|
+
|
|
9
|
+
Initial public release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `demowright run <config.js>` — record a demo from a config, with captions, a
|
|
13
|
+
synthetic cursor, auto-zoom, highlights, and an end card baked into the video.
|
|
14
|
+
- `demowright init` — scaffold a starter `demowright.config.js`.
|
|
15
|
+
- Step types: `caption`, `captionHide`, `goto`, `move`, `click`, `type`, `key`,
|
|
16
|
+
`highlight`, `highlightHide`, `zoom`, `zoomReset`, `scroll`, `wait`, `endcard`.
|
|
17
|
+
- `auth` block — log in once in a throwaway, non-recorded context; credentials
|
|
18
|
+
are read from the environment so they never appear in the config or the video.
|
|
19
|
+
- Social crops from a single capture: `landscape`, `square`, `vertical`.
|
|
20
|
+
- Library API: `recordDemo`, `defineDemo`.
|
|
21
|
+
- Bundled ffmpeg via `ffmpeg-static`; no system dependency beyond Chromium.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matteo Perino
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# demowright
|
|
2
|
+
|
|
3
|
+
Product demo videos rot the moment you touch the UI. You record a beautiful 40-second walkthrough, ship a redesign two weeks later, and now the video is a lie — but re-recording it by hand is annoying enough that nobody does it.
|
|
4
|
+
|
|
5
|
+
So I wrote this: a demo video you describe as a **script that lives in your repo**. Playwright drives the browser, and the polish — captions, a smooth synthetic cursor, auto-zoom on what matters, an end card — is painted straight into the recording. No external editor, no SaaS, no "click here to start your trial". When the UI changes, you re-run it in CI and the video is current again.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --save-dev @matte97p/demowright
|
|
9
|
+
npx playwright install chromium # the browser it drives
|
|
10
|
+
npx demowright init # writes a starter demowright.config.js
|
|
11
|
+
npx demowright run demowright.config.js -o output/demo.mp4
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## What a demo looks like
|
|
15
|
+
|
|
16
|
+
A demo is a plain object: where to start, and an ordered list of steps. This is the bundled example (`examples/local-demo.config.js`):
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { defineDemo } from 'demowright'
|
|
20
|
+
|
|
21
|
+
export default defineDemo({
|
|
22
|
+
name: 'local-demo',
|
|
23
|
+
url: 'http://localhost:3000',
|
|
24
|
+
viewport: { width: 1280, height: 720 },
|
|
25
|
+
theme: { accent: '#e91e63' },
|
|
26
|
+
formats: ['landscape'], // add 'square' and 'vertical' for social
|
|
27
|
+
steps: [
|
|
28
|
+
{ type: 'caption', text: 'Too many items in the sidebar.', duration: 2600 },
|
|
29
|
+
{ type: 'highlight', selector: '.sidebar', duration: 1500 },
|
|
30
|
+
{ type: 'zoom', selector: '.sidebar', scale: 1.2 },
|
|
31
|
+
{ type: 'zoomReset' },
|
|
32
|
+
{ type: 'caption', text: 'Or: just ask.' },
|
|
33
|
+
{ type: 'type', selector: '#q', text: 'How does ChatGPT see me?' },
|
|
34
|
+
{ type: 'click', selector: '#ask' },
|
|
35
|
+
{ type: 'wait', selector: '#result .done' },
|
|
36
|
+
{ type: 'zoom', selector: '.assistant', scale: 1.25 },
|
|
37
|
+
{ type: 'endcard', title: 'My Product', subtitle: 'myproduct.com' },
|
|
38
|
+
],
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Run it and you get `output/demo.mp4` — captions, cursor, and zooms baked in.
|
|
43
|
+
|
|
44
|
+
## Steps
|
|
45
|
+
|
|
46
|
+
| type | fields | what it does |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `caption` | `text`, `duration?`, `hold?` | show a caption (bottom center). `hold: true` keeps it until `captionHide` |
|
|
49
|
+
| `captionHide` | — | hide the current caption |
|
|
50
|
+
| `goto` | `url` | navigate mid-demo (overlay re-installs automatically) |
|
|
51
|
+
| `move` | `selector` \| `x`+`y`, `duration?` | glide the synthetic cursor |
|
|
52
|
+
| `click` | `selector`, `duration?` | move the cursor there, pulse, and really click |
|
|
53
|
+
| `type` | `selector`, `text`, `perChar?`, `clear?` | focus and type, character by character |
|
|
54
|
+
| `key` | `key` | press a key (e.g. `"Enter"`) |
|
|
55
|
+
| `highlight` | `selector`, `pad?`, `duration?` | draw a ring around an element |
|
|
56
|
+
| `highlightHide` | — | remove the ring |
|
|
57
|
+
| `zoom` | `selector`, `scale?`, `duration?` | smoothly zoom toward an element |
|
|
58
|
+
| `zoomReset` | `duration?` | zoom back out |
|
|
59
|
+
| `scroll` | `selector` \| `y`, `duration?` | smooth-scroll to an element or offset |
|
|
60
|
+
| `wait` | `duration` \| `selector` | pause for ms, or until an element is visible |
|
|
61
|
+
| `endcard` | `title`, `subtitle?`, `duration?` | full-screen closing card |
|
|
62
|
+
|
|
63
|
+
Timing is real-time: a `caption` with `duration: 2600` is on screen for 2.6 seconds of video. `wait` with a `selector` is how you sync to your app actually doing something (a request finishing, a result rendering) instead of guessing milliseconds.
|
|
64
|
+
|
|
65
|
+
## CLI
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
demowright run <config.js> [options]
|
|
69
|
+
-o, --out <file> output path (default: output/<name>.mp4)
|
|
70
|
+
-f, --format <list> landscape,square,vertical (overrides config)
|
|
71
|
+
-m, --music <file> background music track
|
|
72
|
+
--keep-raw keep the intermediate .webm
|
|
73
|
+
|
|
74
|
+
demowright init [dir] write a starter config
|
|
75
|
+
demowright --version
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## As a library
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
import { recordDemo } from 'demowright'
|
|
82
|
+
|
|
83
|
+
const { outputs } = await recordDemo(demo, {
|
|
84
|
+
out: 'output/demo.mp4',
|
|
85
|
+
formats: ['landscape', 'vertical'],
|
|
86
|
+
onStep: (i, step) => console.log(i, step.type),
|
|
87
|
+
})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Social formats
|
|
91
|
+
|
|
92
|
+
One capture, three crops — so you don't record three times:
|
|
93
|
+
|
|
94
|
+
- `landscape` — 1280×720, for the site / YouTube / X
|
|
95
|
+
- `square` — 1080×1080, center-cropped, for the LinkedIn / Instagram feed
|
|
96
|
+
- `vertical` — 1080×1920, the landscape centered over a blurred fill, for Reels / Shorts
|
|
97
|
+
|
|
98
|
+
## How it works
|
|
99
|
+
|
|
100
|
+
`addInitScript` installs a tiny overlay runtime (`window.__dw`) into the page before its own scripts run, so it survives navigation. The runner drives it over `page.evaluate` while Playwright records the context video. Because the overlay is real DOM and the zoom is a CSS transform on `<body>`, all of it is captured in the same frames — there is no compositing step. The raw `.webm` is then muxed to H.264 MP4 (and any extra crops) with a bundled static ffmpeg.
|
|
101
|
+
|
|
102
|
+
The overlay attaches itself to `<html>` rather than `<body>`, so the zoom transform never scales the captions or the cursor — they stay crisp while the page zooms underneath them.
|
|
103
|
+
|
|
104
|
+
## Requirements
|
|
105
|
+
|
|
106
|
+
- Node ≥ 20
|
|
107
|
+
- Chromium, via `npx playwright install chromium` (headless — runs fine on a server / in CI with no display)
|
|
108
|
+
- ffmpeg is bundled (`ffmpeg-static`); nothing to install on the system
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT © Matteo Perino
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { recordDemo, estimateDurationMs, normalizeDemo } from '../src/index.js'
|
|
6
|
+
import { scaffold } from '../src/scaffold.js'
|
|
7
|
+
|
|
8
|
+
const HELP = `demowright — record polished product demo videos from a script
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
demowright run <config.js> [options] capture a demo and render MP4(s)
|
|
12
|
+
demowright init [dir] write a starter demowright.config.js
|
|
13
|
+
demowright --help | --version
|
|
14
|
+
|
|
15
|
+
Options for "run":
|
|
16
|
+
-o, --out <file> output path (default: output/<name>.mp4)
|
|
17
|
+
-f, --format <list> comma list: landscape,square,vertical (default from config)
|
|
18
|
+
-m, --music <file> background music track (overrides config)
|
|
19
|
+
--keep-raw keep the intermediate .webm and work dir
|
|
20
|
+
`
|
|
21
|
+
|
|
22
|
+
function fmt(ms) {
|
|
23
|
+
return (ms / 1000).toFixed(1) + 's'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function loadConfig(configPath) {
|
|
27
|
+
const abs = path.resolve(process.cwd(), configPath)
|
|
28
|
+
const mod = await import(pathToFileURL(abs).href)
|
|
29
|
+
const demo = mod.default || mod.demo || mod
|
|
30
|
+
if (!demo || !demo.steps) {
|
|
31
|
+
throw new Error('config "' + configPath + '" must default-export a demo object with steps')
|
|
32
|
+
}
|
|
33
|
+
return demo
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function main() {
|
|
37
|
+
const { values, positionals } = parseArgs({
|
|
38
|
+
allowPositionals: true,
|
|
39
|
+
options: {
|
|
40
|
+
out: { type: 'string', short: 'o' },
|
|
41
|
+
format: { type: 'string', short: 'f' },
|
|
42
|
+
music: { type: 'string', short: 'm' },
|
|
43
|
+
'keep-raw': { type: 'boolean', default: false },
|
|
44
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
45
|
+
version: { type: 'boolean', default: false },
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
if (values.version) {
|
|
50
|
+
const { readFile } = await import('node:fs/promises')
|
|
51
|
+
const pkg = JSON.parse(
|
|
52
|
+
await readFile(new URL('../package.json', import.meta.url), 'utf8')
|
|
53
|
+
)
|
|
54
|
+
console.log(pkg.version)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const command = positionals[0]
|
|
59
|
+
if (values.help || !command) {
|
|
60
|
+
console.log(HELP)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (command === 'init') {
|
|
65
|
+
const res = await scaffold(positionals[1] || process.cwd())
|
|
66
|
+
console.log(
|
|
67
|
+
res.created
|
|
68
|
+
? '✓ wrote ' + res.path + '\n edit it, then: npx demowright run ' + path.basename(res.path)
|
|
69
|
+
: '• ' + res.path + ' already exists — left untouched'
|
|
70
|
+
)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (command === 'run') {
|
|
75
|
+
const configPath = positionals[1]
|
|
76
|
+
if (!configPath) throw new Error('usage: demowright run <config.js>')
|
|
77
|
+
const rawDemo = await loadConfig(configPath)
|
|
78
|
+
|
|
79
|
+
const formats = values.format ? values.format.split(',').map((s) => s.trim()).filter(Boolean) : null
|
|
80
|
+
const est = estimateDurationMs(normalizeDemo(rawDemo))
|
|
81
|
+
console.log('▶ recording "' + (rawDemo.name || 'demo') + '" (~' + fmt(est) + ')')
|
|
82
|
+
|
|
83
|
+
const { outputs } = await recordDemo(rawDemo, {
|
|
84
|
+
out: values.out,
|
|
85
|
+
formats,
|
|
86
|
+
music: values.music,
|
|
87
|
+
keepRaw: values['keep-raw'],
|
|
88
|
+
onAuth: () => process.stdout.write(' · logging in…\n'),
|
|
89
|
+
onStep: (i, step) => process.stdout.write(' · ' + String(i + 1).padStart(2) + ' ' + step.type + '\n'),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
console.log('✓ done:')
|
|
93
|
+
for (const o of outputs) console.log(' ' + o.format.padEnd(10) + o.path)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
throw new Error('unknown command "' + command + '" — try: demowright --help')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
main().catch((err) => {
|
|
101
|
+
console.error('✗ ' + (err && err.message ? err.message : err))
|
|
102
|
+
process.exit(1)
|
|
103
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { defineDemo } from '../src/index.js'
|
|
2
|
+
|
|
3
|
+
// A real-world demowright config: "How GeoSuite works", end to end, on the live
|
|
4
|
+
// app — add a brand (the site is scraped into a brand profile), launch a GEO
|
|
5
|
+
// audit, watch it run, read the result, then ask the Geo copilot. The dead waits
|
|
6
|
+
// — the scrape and the multi-minute audit — are recorded in real time and SPED
|
|
7
|
+
// UP in the final video via the `timelapse` field on those waits.
|
|
8
|
+
//
|
|
9
|
+
// Credentials come from the environment so they never live in the file or appear
|
|
10
|
+
// on screen (see the `auth` block below):
|
|
11
|
+
//
|
|
12
|
+
// GEOSUITE_URL=https://your-instance.example \
|
|
13
|
+
// GEOSUITE_EMAIL=you@example.com GEOSUITE_PASSWORD=... \
|
|
14
|
+
// node bin/cli.js run examples/geosuite-howitworks.config.js -o output/geosuite-howitworks.mp4 --keep-raw
|
|
15
|
+
//
|
|
16
|
+
// `init` suppresses the first-run tour and pre-expands the sidebar groups so the
|
|
17
|
+
// nav is clickable from the first frame.
|
|
18
|
+
|
|
19
|
+
const BASE = process.env.GEOSUITE_URL || 'http://localhost:3000'
|
|
20
|
+
|
|
21
|
+
const INIT = `(() => {
|
|
22
|
+
const get = Storage.prototype.getItem
|
|
23
|
+
Storage.prototype.getItem = function (k) {
|
|
24
|
+
if (typeof k === 'string') {
|
|
25
|
+
if (k.indexOf('gs-workspace-tour:') === 0) return '1'
|
|
26
|
+
if (k === 'gs-sidebar-expanded-groups') return JSON.stringify(['Analizza', 'Account', 'Analyze'])
|
|
27
|
+
}
|
|
28
|
+
return get.call(this, k)
|
|
29
|
+
}
|
|
30
|
+
})();`
|
|
31
|
+
|
|
32
|
+
const M = '.gs-admin-modal-shell' // the dialog panel (intercepts clicks)
|
|
33
|
+
|
|
34
|
+
export default defineDemo({
|
|
35
|
+
name: 'geosuite-howitworks',
|
|
36
|
+
url: `${BASE}/app`,
|
|
37
|
+
viewport: { width: 1920, height: 1080 },
|
|
38
|
+
theme: { accent: '#ff3b7a' },
|
|
39
|
+
locale: 'it-IT',
|
|
40
|
+
formats: ['landscape'],
|
|
41
|
+
init: INIT,
|
|
42
|
+
|
|
43
|
+
auth: {
|
|
44
|
+
url: `${BASE}/login`,
|
|
45
|
+
fields: [
|
|
46
|
+
{ selector: 'input[type="email"]', env: 'GEOSUITE_EMAIL' },
|
|
47
|
+
{ selector: 'input[type="password"]', env: 'GEOSUITE_PASSWORD' },
|
|
48
|
+
],
|
|
49
|
+
submit: 'button[type="submit"]',
|
|
50
|
+
waitUrl: '**/app**',
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
steps: [
|
|
54
|
+
{ type: 'wait', selector: '.gs-workspace-sidebar', timeout: 20000 },
|
|
55
|
+
{ type: 'wait', duration: 800 },
|
|
56
|
+
|
|
57
|
+
// --- intro ---
|
|
58
|
+
{ type: 'caption', text: 'GeoSuite: come funziona, dall’inizio.', duration: 2600 },
|
|
59
|
+
{ type: 'highlight', selector: '.gs-admin-hero', pad: 6, duration: 1600 },
|
|
60
|
+
|
|
61
|
+
// --- add a brand (onboarding + profile config) ---
|
|
62
|
+
{ type: 'move', selector: '.gs-workspace-sidebar a[href="/app/brand"]', duration: 550 },
|
|
63
|
+
{ type: 'click', selector: '.gs-workspace-sidebar a[href="/app/brand"]' },
|
|
64
|
+
{ type: 'wait', duration: 2000 },
|
|
65
|
+
{ type: 'caption', text: 'Aggiungi un brand: bastano nome e sito.', duration: 2600 },
|
|
66
|
+
{ type: 'move', selector: '.gs-admin-panel-cta.is-accent', duration: 500 },
|
|
67
|
+
{ type: 'click', selector: '.gs-admin-panel-cta.is-accent' }, // open "Aggiungi brand"
|
|
68
|
+
{ type: 'wait', selector: M, timeout: 10000 },
|
|
69
|
+
{ type: 'wait', duration: 700 },
|
|
70
|
+
{ type: 'move', selector: `${M} input[type="text"]`, duration: 400 },
|
|
71
|
+
{ type: 'type', selector: `${M} input[type="text"]`, text: 'Linear', perChar: 70 },
|
|
72
|
+
{ type: 'type', selector: `${M} input[type="url"]`, text: 'https://linear.app', perChar: 48 },
|
|
73
|
+
{ type: 'wait', duration: 500 },
|
|
74
|
+
{ type: 'caption', text: 'GeoSuite legge il sito e costruisce il profilo del brand.', duration: 2600 },
|
|
75
|
+
{ type: 'click', selector: `${M} .gs-admin-panel-cta.is-accent` }, // "Analizza il sito" → scrape
|
|
76
|
+
// scrape runs ~15-30s; record it and speed it up 8×
|
|
77
|
+
{ type: 'wait', selector: `${M} .gs-starter-profile`, timeout: 90000, timelapse: 8 },
|
|
78
|
+
{ type: 'caption', text: 'Profilo generato: settore, posizionamento, target, personas.', duration: 3200 },
|
|
79
|
+
{ type: 'highlight', selector: `${M} .gs-starter-profile`, pad: 4, duration: 1800 },
|
|
80
|
+
{ type: 'click', selector: `${M} .gs-admin-panel-cta.is-accent` }, // "Crea brand"
|
|
81
|
+
{ type: 'wait', selector: '.gs-table-mini-btn-secondary', timeout: 15000 }, // brand list reloaded (Linear added)
|
|
82
|
+
{ type: 'wait', duration: 1200 },
|
|
83
|
+
|
|
84
|
+
// --- launch a real audit ---
|
|
85
|
+
{ type: 'caption', text: 'Ora lancia l’audit di visibilità sulle AI.', duration: 2600 },
|
|
86
|
+
{ type: 'move', selector: '.gs-table-mini-btn-secondary', duration: 550 },
|
|
87
|
+
{ type: 'click', selector: '.gs-table-mini-btn-secondary' }, // "Lancia audit" (newest row = Linear)
|
|
88
|
+
{ type: 'wait', selector: M, timeout: 10000 },
|
|
89
|
+
{ type: 'wait', duration: 800 },
|
|
90
|
+
{ type: 'select', selector: `${M} select`, contains: 'Linear' },
|
|
91
|
+
{ type: 'wait', duration: 700 },
|
|
92
|
+
{ type: 'caption', text: 'L’audit interroga ChatGPT, Gemini e Perplexity sul brand — decine di volte.', hold: true },
|
|
93
|
+
{ type: 'click', selector: `${M} .gs-admin-panel-cta.is-accent` }, // "Nuovo audit" → POST (~20s) → navigate
|
|
94
|
+
|
|
95
|
+
// --- the audit runs — speed up the dead waits; fail fast if it never starts ---
|
|
96
|
+
{ type: 'wait', selector: '.gs-audit-progress-fill', timeout: 120000, timelapse: 12 }, // POST + load → progress shows
|
|
97
|
+
{ type: 'wait', selector: '.gs-exec-summary', timeout: 1100000, timelapse: 40 }, // the run → ~20s
|
|
98
|
+
{ type: 'captionHide' },
|
|
99
|
+
{ type: 'wait', duration: 1000 },
|
|
100
|
+
|
|
101
|
+
// --- the result ---
|
|
102
|
+
{ type: 'caption', text: 'Il risultato: punteggio di visibilità, competitor, cosa migliorare.', duration: 3200 },
|
|
103
|
+
{ type: 'highlight', selector: '.gs-score-range', pad: 6, duration: 2000 },
|
|
104
|
+
{ type: 'scroll', selector: '.gs-mp-rankings', duration: 800 },
|
|
105
|
+
{ type: 'caption', text: 'Come ti posizioni contro i competitor nelle risposte AI.', duration: 3000 },
|
|
106
|
+
{ type: 'highlight', selector: '.gs-mp-rankings', pad: 6, duration: 2000 },
|
|
107
|
+
{ type: 'wait', duration: 600 },
|
|
108
|
+
|
|
109
|
+
// --- ask Geo ---
|
|
110
|
+
{ type: 'caption', text: 'E il copilota Geo risponde sui tuoi dati.', duration: 2600 },
|
|
111
|
+
{ type: 'move', selector: '.gs-workspace-sidebar a[href="/app/assistant"]', duration: 550 },
|
|
112
|
+
{ type: 'click', selector: '.gs-workspace-sidebar a[href="/app/assistant"]' },
|
|
113
|
+
{ type: 'wait', selector: '.gs-asst-composer-input', timeout: 20000 },
|
|
114
|
+
{ type: 'wait', duration: 700 },
|
|
115
|
+
{ type: 'type', selector: '.gs-asst-composer-input', text: 'Qual è il punteggio di visibilità AI del mio ultimo audit?', perChar: 30 },
|
|
116
|
+
{ type: 'click', selector: '.gs-asst-composer button' },
|
|
117
|
+
{ type: 'caption', text: 'Geo legge l’audit e risponde.', hold: true },
|
|
118
|
+
{ type: 'wait', selector: '.gs-asst-feedback', timeout: 120000 },
|
|
119
|
+
{ type: 'captionHide' },
|
|
120
|
+
{ type: 'wait', duration: 800 },
|
|
121
|
+
{ type: 'zoom', selector: '.gs-asst-bubble-assistant', scale: 1.08, duration: 700 },
|
|
122
|
+
{ type: 'wait', duration: 2400 },
|
|
123
|
+
{ type: 'zoomReset', duration: 550 },
|
|
124
|
+
|
|
125
|
+
{ type: 'endcard', title: 'GeoSuite', subtitle: 'trygeosuite.it', duration: 2800 },
|
|
126
|
+
],
|
|
127
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { defineDemo } from '../src/index.js'
|
|
2
|
+
|
|
3
|
+
// A real-world demowright config: a workspace tour ending on a completed audit's
|
|
4
|
+
// results, at 1920×1080. Uses only pre-computed content (no live LLM calls), so
|
|
5
|
+
// it renders reliably in CI. Live add-brand scrape and the Geo assistant are
|
|
6
|
+
// omitted here — see geosuite-howitworks.config.js for those.
|
|
7
|
+
//
|
|
8
|
+
// Credentials come from the environment (see the `auth` block):
|
|
9
|
+
//
|
|
10
|
+
// GEOSUITE_URL=https://your-instance.example \
|
|
11
|
+
// GEOSUITE_EMAIL=you@example.com GEOSUITE_PASSWORD=... \
|
|
12
|
+
// node bin/cli.js run examples/geosuite-workspace-audit.config.js -o output/geosuite-workspace-audit.mp4
|
|
13
|
+
|
|
14
|
+
const BASE = process.env.GEOSUITE_URL || 'http://localhost:3000'
|
|
15
|
+
|
|
16
|
+
const INIT = `(() => {
|
|
17
|
+
const get = Storage.prototype.getItem
|
|
18
|
+
Storage.prototype.getItem = function (k) {
|
|
19
|
+
if (typeof k === 'string') {
|
|
20
|
+
if (k.indexOf('gs-workspace-tour:') === 0) return '1'
|
|
21
|
+
if (k === 'gs-sidebar-expanded-groups') return JSON.stringify(['Analizza', 'Account', 'Analyze'])
|
|
22
|
+
}
|
|
23
|
+
return get.call(this, k)
|
|
24
|
+
}
|
|
25
|
+
})();`
|
|
26
|
+
|
|
27
|
+
export default defineDemo({
|
|
28
|
+
name: 'geosuite-workspace-audit',
|
|
29
|
+
url: `${BASE}/app`,
|
|
30
|
+
viewport: { width: 1920, height: 1080 },
|
|
31
|
+
theme: { accent: '#ff3b7a' },
|
|
32
|
+
locale: 'it-IT',
|
|
33
|
+
formats: ['landscape'],
|
|
34
|
+
init: INIT,
|
|
35
|
+
|
|
36
|
+
auth: {
|
|
37
|
+
url: `${BASE}/login`,
|
|
38
|
+
fields: [
|
|
39
|
+
{ selector: 'input[type="email"]', env: 'GEOSUITE_EMAIL' },
|
|
40
|
+
{ selector: 'input[type="password"]', env: 'GEOSUITE_PASSWORD' },
|
|
41
|
+
],
|
|
42
|
+
submit: 'button[type="submit"]',
|
|
43
|
+
waitUrl: '**/app**',
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
steps: [
|
|
47
|
+
{ type: 'wait', selector: '.gs-workspace-sidebar', timeout: 20000 },
|
|
48
|
+
{ type: 'wait', duration: 800 },
|
|
49
|
+
|
|
50
|
+
// cockpit
|
|
51
|
+
{ type: 'caption', text: 'GeoSuite: la tua visibilità sulle AI, in un posto solo.', duration: 2800 },
|
|
52
|
+
{ type: 'highlight', selector: '.gs-admin-hero', pad: 6, duration: 1600 },
|
|
53
|
+
{ type: 'caption', text: 'Brand, audit e i tuoi dati — tutto qui.', duration: 2400 },
|
|
54
|
+
|
|
55
|
+
// brand profile
|
|
56
|
+
{ type: 'move', selector: '.gs-workspace-sidebar a[href="/app/brand"]', duration: 550 },
|
|
57
|
+
{ type: 'click', selector: '.gs-workspace-sidebar a[href="/app/brand"]' },
|
|
58
|
+
{ type: 'wait', duration: 2200 },
|
|
59
|
+
{ type: 'caption', text: 'Il profilo del brand e il suo punteggio di visibilità GEO.', duration: 3000 },
|
|
60
|
+
{ type: 'highlight', selector: '.gs-admin-panel', pad: 4, duration: 2000 },
|
|
61
|
+
|
|
62
|
+
// audit center
|
|
63
|
+
{ type: 'move', selector: '.gs-workspace-sidebar a[href="/app/audits"]', duration: 550 },
|
|
64
|
+
{ type: 'click', selector: '.gs-workspace-sidebar a[href="/app/audits"]' },
|
|
65
|
+
{ type: 'wait', duration: 2200 },
|
|
66
|
+
{ type: 'caption', text: 'Gli audit misurano quanto le AI ti citano.', duration: 2800 },
|
|
67
|
+
{ type: 'highlight', selector: '.gs-admin-tile-row', pad: 6, duration: 1900 },
|
|
68
|
+
|
|
69
|
+
// a real completed audit's results
|
|
70
|
+
{ type: 'move', selector: 'a[href*="/app/audits/"]', duration: 550 },
|
|
71
|
+
{ type: 'click', selector: 'a[href*="/app/audits/"]' },
|
|
72
|
+
{ type: 'wait', selector: '.gs-exec-summary', timeout: 30000 },
|
|
73
|
+
{ type: 'wait', duration: 1000 },
|
|
74
|
+
{ type: 'caption', text: 'Dentro un audit: punteggio, competitor, cosa migliorare.', duration: 3200 },
|
|
75
|
+
{ type: 'highlight', selector: '.gs-score-range', pad: 6, duration: 2200 },
|
|
76
|
+
{ type: 'scroll', selector: '.gs-mp-rankings', duration: 800 },
|
|
77
|
+
{ type: 'caption', text: 'Come ti posizioni contro i competitor nelle risposte AI.', duration: 3000 },
|
|
78
|
+
{ type: 'highlight', selector: '.gs-mp-rankings', pad: 6, duration: 2000 },
|
|
79
|
+
{ type: 'wait', duration: 800 },
|
|
80
|
+
|
|
81
|
+
{ type: 'endcard', title: 'GeoSuite', subtitle: 'trygeosuite.it', duration: 2800 },
|
|
82
|
+
],
|
|
83
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
3
|
+
import { defineDemo } from '../src/index.js'
|
|
4
|
+
|
|
5
|
+
// Point the demo at the bundled local site (portable across machines).
|
|
6
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const siteUrl = pathToFileURL(path.join(here, 'site', 'index.html')).href
|
|
8
|
+
|
|
9
|
+
// The narrative mirrors the GeoSuite "geobot" story on purpose: a crowded
|
|
10
|
+
// sidebar → "just ask" → the assistant runs the right tool by itself.
|
|
11
|
+
export default defineDemo({
|
|
12
|
+
name: 'local-demo',
|
|
13
|
+
url: siteUrl,
|
|
14
|
+
viewport: { width: 1280, height: 720 },
|
|
15
|
+
theme: { accent: '#e91e63' },
|
|
16
|
+
formats: ['landscape'],
|
|
17
|
+
steps: [
|
|
18
|
+
{ type: 'caption', text: '18 voci nella sidebar.\nTrova tu quella giusta.', duration: 2800 },
|
|
19
|
+
{ type: 'highlight', selector: '.sidebar', pad: 4, duration: 1500 },
|
|
20
|
+
{ type: 'zoom', selector: '.sidebar', scale: 1.18 },
|
|
21
|
+
{ type: 'wait', duration: 900 },
|
|
22
|
+
{ type: 'zoomReset' },
|
|
23
|
+
{ type: 'highlightHide' },
|
|
24
|
+
{ type: 'caption', text: 'Oppure: chiedi e basta.', duration: 2200 },
|
|
25
|
+
{ type: 'move', selector: '#q', duration: 700 },
|
|
26
|
+
{ type: 'type', selector: '#q', text: 'Come mi vede ChatGPT rispetto ai competitor?', perChar: 40 },
|
|
27
|
+
{ type: 'click', selector: '#ask' },
|
|
28
|
+
{ type: 'caption', text: "L'assistente lancia il tool da solo.", duration: 2600 },
|
|
29
|
+
{ type: 'wait', selector: '#result .done', timeout: 15000 },
|
|
30
|
+
{ type: 'wait', duration: 600 },
|
|
31
|
+
{ type: 'zoom', selector: '.assistant', scale: 1.25 },
|
|
32
|
+
{ type: 'wait', duration: 1600 },
|
|
33
|
+
{ type: 'zoomReset' },
|
|
34
|
+
{ type: 'endcard', title: 'demowright', subtitle: 'demo as code', duration: 2600 },
|
|
35
|
+
],
|
|
36
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="it">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Demo App</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
html, body { margin: 0; height: 100%; }
|
|
10
|
+
body {
|
|
11
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
12
|
+
background: #0b0b0e;
|
|
13
|
+
color: #e7e7ea;
|
|
14
|
+
display: flex;
|
|
15
|
+
height: 100vh;
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
}
|
|
18
|
+
.sidebar {
|
|
19
|
+
width: 240px;
|
|
20
|
+
flex: 0 0 240px;
|
|
21
|
+
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
|
22
|
+
background: rgba(255, 255, 255, 0.02);
|
|
23
|
+
padding: 16px 10px;
|
|
24
|
+
overflow-y: auto;
|
|
25
|
+
}
|
|
26
|
+
.brand { font-weight: 800; font-size: 18px; padding: 6px 10px 14px; letter-spacing: -0.3px; }
|
|
27
|
+
.brand span { color: #e91e63; }
|
|
28
|
+
.nav-group { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: #75757e; padding: 12px 10px 6px; }
|
|
29
|
+
.nav-item {
|
|
30
|
+
display: flex; align-items: center; gap: 10px;
|
|
31
|
+
padding: 8px 10px; border-radius: 8px;
|
|
32
|
+
font-size: 13px; color: #b9b9c2; cursor: pointer;
|
|
33
|
+
}
|
|
34
|
+
.nav-item:hover { background: rgba(255, 255, 255, 0.05); color: #fff; }
|
|
35
|
+
.nav-item .dot { width: 6px; height: 6px; border-radius: 50%; background: #44444c; }
|
|
36
|
+
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
|
37
|
+
.topbar { padding: 16px 28px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); font-weight: 600; }
|
|
38
|
+
.stage { flex: 1; display: flex; align-items: center; justify-content: center; padding: 28px; }
|
|
39
|
+
.assistant {
|
|
40
|
+
width: 100%; max-width: 620px;
|
|
41
|
+
background: rgba(255, 255, 255, 0.03);
|
|
42
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
43
|
+
border-radius: 18px; padding: 22px;
|
|
44
|
+
}
|
|
45
|
+
.assistant h2 { margin: 0 0 4px; font-size: 20px; }
|
|
46
|
+
.assistant p.sub { margin: 0 0 18px; color: #8a8a93; font-size: 13px; }
|
|
47
|
+
.composer { display: flex; gap: 10px; }
|
|
48
|
+
#q {
|
|
49
|
+
flex: 1; resize: none; height: 46px;
|
|
50
|
+
background: #141418; color: #fff;
|
|
51
|
+
border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 12px;
|
|
52
|
+
padding: 12px 14px; font-size: 15px; font-family: inherit;
|
|
53
|
+
}
|
|
54
|
+
#q:focus { outline: none; border-color: #e91e63; }
|
|
55
|
+
#ask {
|
|
56
|
+
border: none; border-radius: 12px; padding: 0 20px;
|
|
57
|
+
background: #e91e63; color: #fff; font-weight: 700; font-size: 14px; cursor: pointer;
|
|
58
|
+
}
|
|
59
|
+
#result { margin-top: 18px; min-height: 10px; }
|
|
60
|
+
.think { color: #8a8a93; font-size: 13px; display: flex; gap: 8px; align-items: center; }
|
|
61
|
+
.tool {
|
|
62
|
+
display: inline-flex; align-items: center; gap: 8px;
|
|
63
|
+
background: rgba(233, 30, 99, 0.12); color: #ff7aa8;
|
|
64
|
+
border: 1px solid rgba(233, 30, 99, 0.3);
|
|
65
|
+
border-radius: 999px; padding: 5px 12px; font-size: 12px; font-weight: 600; margin: 6px 0;
|
|
66
|
+
}
|
|
67
|
+
.answer { font-size: 15px; line-height: 1.55; }
|
|
68
|
+
.cursor::after { content: "▍"; color: #e91e63; animation: blink 1s steps(2) infinite; }
|
|
69
|
+
@keyframes blink { 50% { opacity: 0; } }
|
|
70
|
+
</style>
|
|
71
|
+
</head>
|
|
72
|
+
<body>
|
|
73
|
+
<aside class="sidebar">
|
|
74
|
+
<div class="brand">Demo<span>App</span></div>
|
|
75
|
+
<div class="nav-group">Workspace</div>
|
|
76
|
+
<div class="nav-item"><span class="dot"></span>Overview</div>
|
|
77
|
+
<div class="nav-item"><span class="dot"></span>Store & Catalog</div>
|
|
78
|
+
<div class="nav-group">Insights</div>
|
|
79
|
+
<div class="nav-item"><span class="dot"></span>Analytics</div>
|
|
80
|
+
<div class="nav-item"><span class="dot"></span>Categories</div>
|
|
81
|
+
<div class="nav-item"><span class="dot"></span>Products</div>
|
|
82
|
+
<div class="nav-item"><span class="dot"></span>Competitors</div>
|
|
83
|
+
<div class="nav-item"><span class="dot"></span>Citations</div>
|
|
84
|
+
<div class="nav-group">Tools</div>
|
|
85
|
+
<div class="nav-item"><span class="dot"></span>Brief Generator</div>
|
|
86
|
+
<div class="nav-item"><span class="dot"></span>Citation Strategy</div>
|
|
87
|
+
<div class="nav-item"><span class="dot"></span>Audience Intent</div>
|
|
88
|
+
<div class="nav-item"><span class="dot"></span>Content Planner</div>
|
|
89
|
+
<div class="nav-item"><span class="dot"></span>Product Optimizer</div>
|
|
90
|
+
<div class="nav-group">Account</div>
|
|
91
|
+
<div class="nav-item"><span class="dot"></span>Audits</div>
|
|
92
|
+
<div class="nav-item"><span class="dot"></span>Audit History</div>
|
|
93
|
+
<div class="nav-item"><span class="dot"></span>Reports</div>
|
|
94
|
+
<div class="nav-item"><span class="dot"></span>Settings</div>
|
|
95
|
+
<div class="nav-item"><span class="dot"></span>Billing</div>
|
|
96
|
+
</aside>
|
|
97
|
+
|
|
98
|
+
<div class="main">
|
|
99
|
+
<div class="topbar">Assistant</div>
|
|
100
|
+
<div class="stage">
|
|
101
|
+
<div class="assistant">
|
|
102
|
+
<h2>Ask the assistant</h2>
|
|
103
|
+
<p class="sub">No more hunting through the sidebar — just say what you need.</p>
|
|
104
|
+
<div class="composer">
|
|
105
|
+
<textarea id="q" placeholder="Type a question…"></textarea>
|
|
106
|
+
<button id="ask">Ask</button>
|
|
107
|
+
</div>
|
|
108
|
+
<div id="result"></div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<script>
|
|
114
|
+
const result = document.getElementById('result')
|
|
115
|
+
document.getElementById('ask').addEventListener('click', () => {
|
|
116
|
+
result.innerHTML = '<div class="think">Thinking…</div>'
|
|
117
|
+
setTimeout(() => {
|
|
118
|
+
result.innerHTML = '<div class="tool">⚙ running tool: brand-audit</div>'
|
|
119
|
+
}, 900)
|
|
120
|
+
setTimeout(() => {
|
|
121
|
+
result.innerHTML =
|
|
122
|
+
'<div class="tool">⚙ brand-audit · done</div>' +
|
|
123
|
+
'<div class="answer cursor" id="ans"></div>'
|
|
124
|
+
const ans = document.getElementById('ans')
|
|
125
|
+
const text =
|
|
126
|
+
'ChatGPT mentions your brand in 3 of 10 comparison prompts, ' +
|
|
127
|
+
'usually behind two competitors. Strongest on price, weakest on reviews.'
|
|
128
|
+
let i = 0
|
|
129
|
+
const tick = setInterval(() => {
|
|
130
|
+
ans.textContent = text.slice(0, (i += 2))
|
|
131
|
+
if (i >= text.length) {
|
|
132
|
+
clearInterval(tick)
|
|
133
|
+
ans.classList.remove('cursor')
|
|
134
|
+
ans.classList.add('done')
|
|
135
|
+
}
|
|
136
|
+
}, 28)
|
|
137
|
+
}, 1900)
|
|
138
|
+
})
|
|
139
|
+
</script>
|
|
140
|
+
</body>
|
|
141
|
+
</html>
|