@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 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 &amp; 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>