mirror of
https://github.com/lennyzeltser/conversation-replay
synced 2026-06-21 13:55:53 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
../../CLAUDE.md
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Agents
|
||||||
|
|
||||||
|
Before working on this project, review [README.md](./README.md) for:
|
||||||
|
- Project purpose: CLI tool generating animated conversation demos from YAML
|
||||||
|
- YAML schema for scenarios, participants, and steps
|
||||||
|
- Repository structure and key source files
|
||||||
|
- Development commands and architecture
|
||||||
|
- Security considerations (input validation, output safety)
|
||||||
|
|
||||||
|
See the **AI Agent Quick Reference** section for file locations and common tasks.
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Zeltser Security Corp.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
# Conversation Replay
|
||||||
|
|
||||||
|
Create animated conversation demos from YAML for security awareness, IR training, and communication skills education.
|
||||||
|
|
||||||
|
**"VHS for conversations"** — Define conversations declaratively, generate self-contained HTML demos that play back like videos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- [Why This Matters](#why-this-matters)
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [YAML Schema](#yaml-schema)
|
||||||
|
- [Output Features](#output-features)
|
||||||
|
- [Embedding in Websites](#embedding-in-websites)
|
||||||
|
- [Use Cases](#use-cases)
|
||||||
|
- [Development](#development)
|
||||||
|
- [AI Agent Quick Reference](#ai-agent-quick-reference)
|
||||||
|
- [Security](#security)
|
||||||
|
- [Author](#author)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
Security training often involves showing how attacks unfold through conversation — phishing emails, social engineering calls, BEC attempts. Static screenshots lose the temporal element. Video production is time-consuming and hard to update.
|
||||||
|
|
||||||
|
Conversation Replay lets you:
|
||||||
|
|
||||||
|
- **Define conversations in YAML** — Easy to write, review, and version control
|
||||||
|
- **Generate self-contained HTML** — No external dependencies, works offline
|
||||||
|
- **Embed anywhere** — Drop into articles, training materials, or presentations
|
||||||
|
- **Update easily** — Change the YAML, regenerate the HTML
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
**Want to see it in action first?** Open [examples/london-scam.html](examples/london-scam.html) or [examples/ir-report.html](examples/ir-report.html) directly in your browser — no build step required.
|
||||||
|
|
||||||
|
**To build your own demos:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prerequisite: Bun (https://bun.sh) or Node.js 18+
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Build a demo from YAML
|
||||||
|
bun run src/cli.ts build examples/london-scam.yaml -o demo.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
```
|
||||||
|
Loading examples/london-scam.yaml...
|
||||||
|
Building demo.html...
|
||||||
|
Done! Generated demo.html
|
||||||
|
Title: London Scam - Social Engineering Demo
|
||||||
|
Scenarios: 1
|
||||||
|
- London Scam: 17 steps (Matt (compromised account), Rakesh)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open in browser
|
||||||
|
open demo.html
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated HTML is completely self-contained — CSS, JavaScript, and content are all inlined. No external dependencies, works offline.
|
||||||
|
|
||||||
|
## YAML Schema
|
||||||
|
|
||||||
|
### Basic Structure
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
meta:
|
||||||
|
title: "Demo Title"
|
||||||
|
description: "Shown in header"
|
||||||
|
theme: chat # chat | email | slack | terminal | generic
|
||||||
|
autoAdvance: true # Auto-advance between scenarios
|
||||||
|
|
||||||
|
scenarios:
|
||||||
|
- id: scenario-1
|
||||||
|
title: "Scenario Name" # Shown in tab (if multiple scenarios)
|
||||||
|
participants:
|
||||||
|
- id: attacker
|
||||||
|
label: "Scammer"
|
||||||
|
role: left # Messages appear on left
|
||||||
|
- id: victim
|
||||||
|
label: "Target"
|
||||||
|
role: right # Messages appear on right
|
||||||
|
steps:
|
||||||
|
- type: message
|
||||||
|
from: attacker
|
||||||
|
content: "Hey, how are you?"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step Types
|
||||||
|
|
||||||
|
| Type | Purpose | Renders As |
|
||||||
|
|------|---------|------------|
|
||||||
|
| `message` | Conversation turn | Chat bubble aligned left or right based on participant role |
|
||||||
|
| `annotation` | Educational note | Highlighted callout with vertical accent bar |
|
||||||
|
| `transition` | Scene break | Centered text card indicating time/scene change |
|
||||||
|
|
||||||
|
### Message Options
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Basic message
|
||||||
|
- type: message
|
||||||
|
from: attacker
|
||||||
|
content: "Transfer the funds immediately."
|
||||||
|
|
||||||
|
# Message with code block
|
||||||
|
- type: message
|
||||||
|
from: analyst
|
||||||
|
content: "Here's what I found in the logs:"
|
||||||
|
codeBlock: |
|
||||||
|
error: unauthorized access attempt
|
||||||
|
source: 192.168.1.105
|
||||||
|
timestamp: 2024-01-15T14:23:00Z
|
||||||
|
|
||||||
|
# Message with footnote
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "I can help with that."
|
||||||
|
footnote: "The AI retrieves context from the MCP server"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Meta Options
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- `title` — Demo title shown in header
|
||||||
|
|
||||||
|
**Display options:**
|
||||||
|
```yaml
|
||||||
|
meta:
|
||||||
|
description: "Shown below title"
|
||||||
|
theme: chat # chat | email | slack | terminal | generic
|
||||||
|
articleUrl: "/related-article" # "View Article" link in header
|
||||||
|
annotationLabel: "Security Note" # Label for annotations (default: "Behind the Scenes")
|
||||||
|
timerStyle: circle # circle | bar (progress indicator style)
|
||||||
|
cornerStyle: rounded # rounded | straight (bubble corners)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior options:**
|
||||||
|
```yaml
|
||||||
|
meta:
|
||||||
|
autoAdvance: true # Auto-play next scenario when current ends
|
||||||
|
hideHeaderInIframe: true # Hide header when embedded (default: true)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom colors:**
|
||||||
|
```yaml
|
||||||
|
meta:
|
||||||
|
colors:
|
||||||
|
accent: "#1a45bc" # Buttons, links
|
||||||
|
pageBg: "#f6f7f9" # Page background (standalone mode)
|
||||||
|
canvasBg: "#ffffff" # Chat container background
|
||||||
|
leftBg: "#e0f2fe" # Left participant bubble background
|
||||||
|
leftBorder: "#7dd3fc" # Left participant bubble border
|
||||||
|
rightBg: "#f0fdf4" # Right participant bubble background
|
||||||
|
rightBorder: "#86efac" # Right participant bubble border
|
||||||
|
tabInactiveColor: "#666666" # Inactive tab text
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timing configuration:**
|
||||||
|
```yaml
|
||||||
|
meta:
|
||||||
|
speed:
|
||||||
|
minDelay: 3000 # Minimum pause between steps (ms)
|
||||||
|
maxDelay: 8000 # Maximum pause between steps (ms)
|
||||||
|
msPerWord: 200 # Reading time per word
|
||||||
|
annotationMultiplier: 1.15 # Extra time multiplier for annotations
|
||||||
|
upNextDelay: 2500 # How long to show "Up Next" before transitioning
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Scenario Demos
|
||||||
|
|
||||||
|
When you define multiple scenarios, they appear as tabs:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scenarios:
|
||||||
|
- id: create
|
||||||
|
title: "Creating Reports"
|
||||||
|
# ...steps...
|
||||||
|
|
||||||
|
- id: review
|
||||||
|
title: "Reviewing Reports"
|
||||||
|
# ...steps...
|
||||||
|
|
||||||
|
- id: tips
|
||||||
|
title: "Writing Tips"
|
||||||
|
# ...steps...
|
||||||
|
```
|
||||||
|
|
||||||
|
With `autoAdvance: true`, the demo automatically transitions between scenarios.
|
||||||
|
|
||||||
|
## Output Features
|
||||||
|
|
||||||
|
Generated HTML files include:
|
||||||
|
|
||||||
|
- **Zero external dependencies** — Everything inlined
|
||||||
|
- **Dark mode support** — Respects `prefers-color-scheme` and syncs with parent page
|
||||||
|
- **Responsive design** — Fills screen standalone, fixed height when embedded
|
||||||
|
- **Accessibility** — Respects `prefers-reduced-motion`, includes ARIA labels, keyboard navigation
|
||||||
|
- **Playback controls** — Play/pause, restart, speed selector (0.5x–4x)
|
||||||
|
- **Tab navigation** — Arrow keys navigate between scenario tabs
|
||||||
|
- **Progress indicator** — Timer and step counter
|
||||||
|
|
||||||
|
## Embedding in Websites
|
||||||
|
|
||||||
|
### Basic Iframe
|
||||||
|
|
||||||
|
```html
|
||||||
|
<iframe
|
||||||
|
src="/demos/security-awareness.html"
|
||||||
|
style="width:100%; height:650px; border:none; border-radius:8px;"
|
||||||
|
loading="lazy"
|
||||||
|
></iframe>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dark Mode Sync
|
||||||
|
|
||||||
|
The demo automatically respects `prefers-color-scheme`. If your site has a manual dark mode toggle, sync it to the iframe:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// When your page toggles dark mode
|
||||||
|
const iframe = document.querySelector('iframe');
|
||||||
|
iframe.contentWindow.postMessage({
|
||||||
|
type: 'theme-change',
|
||||||
|
theme: 'dark' // or 'light'
|
||||||
|
}, '*');
|
||||||
|
|
||||||
|
// On page load, sync initial theme
|
||||||
|
iframe.addEventListener('load', function() {
|
||||||
|
const theme = document.documentElement.getAttribute('data-theme') || 'light';
|
||||||
|
iframe.contentWindow.postMessage({ type: 'theme-change', theme }, '*');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior Differences
|
||||||
|
|
||||||
|
| Context | Behavior |
|
||||||
|
|---------|----------|
|
||||||
|
| **Standalone** | Chat fills available screen height, header visible |
|
||||||
|
| **Embedded (iframe)** | Fixed height, transparent background, header hidden |
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
- **Social engineering awareness** — Show how scams unfold over time ([example](examples/london-scam.html))
|
||||||
|
- **IR training** — Demonstrate incident communication patterns ([example](examples/ir-report.html))
|
||||||
|
- **Communication skills** — Good vs. bad professional dialogue
|
||||||
|
- **Tool demos** — Showcase AI/chatbot interactions
|
||||||
|
- **Phishing education** — Step-by-step attack anatomy
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
conversation-replay/
|
||||||
|
├── src/
|
||||||
|
│ ├── cli.ts # CLI entry point (build, validate commands)
|
||||||
|
│ ├── parser.ts # YAML parsing and validation
|
||||||
|
│ ├── generator.ts # HTML generation with embedded CSS/JS
|
||||||
|
│ └── types.ts # TypeScript type definitions
|
||||||
|
├── examples/
|
||||||
|
│ ├── README.md # Examples documentation
|
||||||
|
│ ├── london-scam.yaml # Single-scenario demo (source)
|
||||||
|
│ ├── london-scam.html # Pre-built HTML demo
|
||||||
|
│ ├── ir-report.yaml # Multi-scenario demo (source)
|
||||||
|
│ └── ir-report.html # Pre-built HTML demo
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
See [examples/README.md](examples/README.md) for detailed descriptions of each demo.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build a demo
|
||||||
|
bun run src/cli.ts build examples/london-scam.yaml -o demo.html
|
||||||
|
|
||||||
|
# Validate scenario without building (catches errors before generation)
|
||||||
|
bun run src/cli.ts validate examples/london-scam.yaml
|
||||||
|
|
||||||
|
# Build with different theme
|
||||||
|
bun run src/cli.ts build examples/london-scam.yaml -o demo.html --theme email
|
||||||
|
|
||||||
|
# Build without header
|
||||||
|
bun run src/cli.ts build examples/london-scam.yaml -o demo.html --no-header
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
conversation-replay build <scenario.yaml> -o <output.html> [options]
|
||||||
|
conversation-replay validate <scenario.yaml>
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-o, --output <path> Output HTML file (required for build)
|
||||||
|
--theme <theme> Override theme: chat, email, slack, terminal, generic
|
||||||
|
--no-header Exclude the demo header
|
||||||
|
-h, --help Show help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding New Themes
|
||||||
|
|
||||||
|
1. Add theme name to `VALID_THEMES` in `src/types.ts`
|
||||||
|
2. Add CSS variables in `generateCss()` in `src/generator.ts`
|
||||||
|
3. Theme is automatically available via `--theme` CLI option
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI Agent Quick Reference
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/types.ts` | TypeScript interfaces defining YAML schema |
|
||||||
|
| `src/parser.ts` | YAML parsing with validation (color sanitization, type checking) |
|
||||||
|
| `src/generator.ts` | HTML generation with embedded CSS/JS (~1700 lines) |
|
||||||
|
| `src/cli.ts` | CLI interface |
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
YAML Input → Parser (validation) → Generator (HTML/CSS/JS) → Self-contained HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
The generator produces a single HTML file with:
|
||||||
|
- All CSS in a `<style>` tag
|
||||||
|
- All JavaScript in a `<script>` tag
|
||||||
|
- Scenario data embedded as JavaScript objects
|
||||||
|
|
||||||
|
### Common Tasks
|
||||||
|
|
||||||
|
**Add a new meta option:**
|
||||||
|
1. Add to interface in `src/types.ts`
|
||||||
|
2. Add validation in `src/parser.ts`
|
||||||
|
3. Use in `src/generator.ts`
|
||||||
|
|
||||||
|
**Modify CSS:**
|
||||||
|
- Edit the template strings in `generateCss()` in `src/generator.ts`
|
||||||
|
|
||||||
|
**Modify JavaScript behavior:**
|
||||||
|
- Edit the template string in `generateJs()` in `src/generator.ts`
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
Run `bun run src/cli.ts validate <file.yaml>` to check a scenario without building. The parser validates:
|
||||||
|
|
||||||
|
- Required fields (title, scenarios, participants, steps)
|
||||||
|
- Type constraints (theme, timerStyle, cornerStyle must be valid enum values)
|
||||||
|
- Color values (blocks CSS injection via semicolons, braces, url())
|
||||||
|
- Speed config (numeric values, minDelay ≤ maxDelay)
|
||||||
|
- Participant references (step.from must match a participant.id)
|
||||||
|
- Unique IDs (no duplicate scenario or participant IDs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
The parser validates all input to prevent:
|
||||||
|
- **CSS injection** — Color values checked against allowlist of valid formats
|
||||||
|
- **XSS via links** — Markdown links block `javascript:`, `data:`, `vbscript:` URLs
|
||||||
|
- **Invalid configurations** — Type checking for all enum values
|
||||||
|
|
||||||
|
### Output Security
|
||||||
|
|
||||||
|
Generated HTML:
|
||||||
|
- Uses `textContent` for all dynamic content (no `innerHTML` with user data)
|
||||||
|
- Self-contained with no external resource loading
|
||||||
|
- No cookies or local storage (except theme preference)
|
||||||
|
|
||||||
|
### Threat Model
|
||||||
|
|
||||||
|
This tool generates static HTML from trusted YAML input. Consider:
|
||||||
|
- YAML files should be treated as code (review before building)
|
||||||
|
- Generated HTML is safe to host publicly
|
||||||
|
- No server-side execution — pure static output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
**[Lenny Zeltser](https://zeltser.com)**: Builder of security products and programs. Teacher of those who run them.
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "conversation-replay",
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.8.2",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.0.8", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
+1438
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
meta:
|
||||||
|
title: Example Conversation
|
||||||
|
description: A test scenario to evaluate the UI.
|
||||||
|
theme: chat
|
||||||
|
|
||||||
|
scenarios:
|
||||||
|
- id: scenario1
|
||||||
|
title: Basic Chat
|
||||||
|
participants:
|
||||||
|
- id: user
|
||||||
|
label: User
|
||||||
|
role: left
|
||||||
|
- id: ai
|
||||||
|
label: Assistant
|
||||||
|
role: right
|
||||||
|
steps:
|
||||||
|
- type: message
|
||||||
|
from: user
|
||||||
|
content: "Hello, can you help me with something?"
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "Of course! What do you need help with?"
|
||||||
|
- type: annotation
|
||||||
|
content: "The assistant is ready to help."
|
||||||
|
- type: message
|
||||||
|
from: user
|
||||||
|
content: "I want to improve my project's design."
|
||||||
|
- type: transition
|
||||||
|
content: "Processing request..."
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "I can definitely help with that. Let's look at the code."
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Examples
|
||||||
|
|
||||||
|
Example YAML source files and their pre-built HTML demos.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `london-scam.yaml` | Single-scenario social engineering demo (source) |
|
||||||
|
| `london-scam.html` | Pre-built HTML demo |
|
||||||
|
| `ir-report.yaml` | Multi-scenario IR report writing demo (source) |
|
||||||
|
| `ir-report.html` | Pre-built HTML demo |
|
||||||
|
|
||||||
|
## Viewing the Demos
|
||||||
|
|
||||||
|
Open any `.html` file directly in your browser — no server required.
|
||||||
|
|
||||||
|
## Example Descriptions
|
||||||
|
|
||||||
|
### London Scam
|
||||||
|
|
||||||
|
**Source:** [Facebook Fraud: A Transcript](https://rake.sh/2009/01/20/facebook-fraud-a-transcript/) by Rakesh Pai
|
||||||
|
|
||||||
|
A social engineering awareness demo showing how attackers use compromised social media accounts to scam victims with the "stranded traveler" pretext.
|
||||||
|
|
||||||
|
**Features demonstrated:** Single scenario with linear flow, `message` steps with left/right participant roles, `annotation` steps for educational callouts, `transition` steps for scene breaks, custom `annotationLabel`.
|
||||||
|
|
||||||
|
### IR Report Writing
|
||||||
|
|
||||||
|
**Source:** [AI Can Help You Create Good Incident Response Reports](https://zeltser.com/good-ir-reports-with-ai) by Lenny Zeltser
|
||||||
|
|
||||||
|
Three-part demo showing how AI can assist with incident response documentation:
|
||||||
|
|
||||||
|
1. **IR Report Creation** — Gathering incident details for a BEC attack
|
||||||
|
2. **IR Report Review** — Improving clarity and eliminating jargon
|
||||||
|
3. **Writing Coach** — Executive summary structure and tone guidance
|
||||||
|
|
||||||
|
**Features demonstrated:** Multiple scenarios with tab navigation, `autoAdvance` for automatic scenario progression, `codeBlock` for report text, `footnote` for supplementary information.
|
||||||
|
|
||||||
|
## Rebuilding the HTML Files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/cli.ts build examples/london-scam.yaml -o examples/london-scam.html
|
||||||
|
bun run src/cli.ts build examples/ir-report.yaml -o examples/ir-report.html
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [main README](../README.md) for the complete YAML schema reference.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
|||||||
|
# Multi-scenario demo: AI-Assisted IR Report Writing
|
||||||
|
# Demonstrates the tab system with three scenarios
|
||||||
|
#
|
||||||
|
# Based on: https://zeltser.com/good-ir-reports-with-ai
|
||||||
|
|
||||||
|
meta:
|
||||||
|
title: "AI-Assisted IR Report Writing"
|
||||||
|
description: "Using AI tools to create, review, and improve incident response documentation"
|
||||||
|
theme: chat
|
||||||
|
autoAdvance: true
|
||||||
|
|
||||||
|
scenarios:
|
||||||
|
- id: create
|
||||||
|
title: "IR Report Creation"
|
||||||
|
participants:
|
||||||
|
- id: analyst
|
||||||
|
label: "Security Analyst"
|
||||||
|
role: left
|
||||||
|
- id: ai
|
||||||
|
label: "AI Assistant"
|
||||||
|
role: right
|
||||||
|
steps:
|
||||||
|
- type: message
|
||||||
|
from: analyst
|
||||||
|
content: "I need to create an IR report for a BEC incident. The CFO received a fraudulent wire transfer request that appeared to be from the CEO."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "I can help you draft that IR report. Let me ask a few questions first to ensure we capture all the relevant details."
|
||||||
|
|
||||||
|
- type: annotation
|
||||||
|
content: "The AI uses a structured approach to gather incident details systematically."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "When was the incident discovered, and what was the initial indicator that something was wrong?"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: analyst
|
||||||
|
content: "Discovered yesterday at 2pm when the CFO called the CEO to confirm the transfer. The CEO had no knowledge of the request."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "Good catch by the CFO. What was the requested transfer amount and where was it supposed to go?"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: analyst
|
||||||
|
content: "$47,500 to an account at First National Bank, routing number ending in 4521."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "Based on what you've shared, here's a draft executive summary:"
|
||||||
|
codeBlock: |
|
||||||
|
EXECUTIVE SUMMARY
|
||||||
|
|
||||||
|
On [date], a Business Email Compromise (BEC) attempt
|
||||||
|
targeted our organization. An attacker impersonating
|
||||||
|
the CEO requested a $47,500 wire transfer from the CFO.
|
||||||
|
|
||||||
|
The attack was detected before funds were transferred
|
||||||
|
when the CFO verbally confirmed the request with the CEO.
|
||||||
|
|
||||||
|
Immediate actions: Email headers analyzed, account
|
||||||
|
security reviewed, finance team alerted to new protocols.
|
||||||
|
|
||||||
|
- type: transition
|
||||||
|
content: "The analyst continues building the full report..."
|
||||||
|
|
||||||
|
- id: review
|
||||||
|
title: "IR Report Review"
|
||||||
|
participants:
|
||||||
|
- id: analyst
|
||||||
|
label: "Security Analyst"
|
||||||
|
role: left
|
||||||
|
- id: ai
|
||||||
|
label: "AI Assistant"
|
||||||
|
role: right
|
||||||
|
steps:
|
||||||
|
- type: message
|
||||||
|
from: analyst
|
||||||
|
content: "Can you review this section of my IR report? I want to make sure it's clear for executives."
|
||||||
|
codeBlock: |
|
||||||
|
The threat actor utilized sophisticated social engineering
|
||||||
|
techniques leveraging compromised email infrastructure to
|
||||||
|
perpetrate a financial fraud attempt via BEC methodology.
|
||||||
|
|
||||||
|
- type: annotation
|
||||||
|
content: "The AI identifies jargon and passive voice that could confuse non-technical readers."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "This section could be clearer for executives. Here are specific suggestions:"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "1. 'sophisticated social engineering techniques' - What specifically made it sophisticated? Was it personalized?"
|
||||||
|
footnote: "Executives appreciate specifics over vague adjectives"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "2. 'leveraging compromised email infrastructure' - Whose email was compromised? The CEO's? An external vendor's?"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "3. 'perpetrate a financial fraud attempt via BEC methodology' - This is passive and jargon-heavy. Try: 'The attacker posed as our CEO to request a fraudulent wire transfer.'"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: analyst
|
||||||
|
content: "Good points. Let me revise that paragraph."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: analyst
|
||||||
|
content: "Revised version:"
|
||||||
|
codeBlock: |
|
||||||
|
An attacker gained access to the CEO's email account
|
||||||
|
and sent a convincing wire transfer request to the CFO.
|
||||||
|
The email matched the CEO's writing style and referenced
|
||||||
|
an actual pending acquisition, showing the attacker had
|
||||||
|
researched our company.
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "Much better. This version is specific, active voice, and tells executives exactly what happened without requiring security background knowledge."
|
||||||
|
|
||||||
|
- id: coach
|
||||||
|
title: "Writing Coach"
|
||||||
|
participants:
|
||||||
|
- id: analyst
|
||||||
|
label: "Security Analyst"
|
||||||
|
role: left
|
||||||
|
- id: ai
|
||||||
|
label: "Writing Coach"
|
||||||
|
role: right
|
||||||
|
steps:
|
||||||
|
- type: message
|
||||||
|
from: analyst
|
||||||
|
content: "I struggle with executive summaries. They either end up too technical or too vague. Any tips?"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "Executive summaries for IR reports should answer four questions in plain language:"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "1. WHAT happened? (One sentence describing the incident)"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "2. WHAT's the impact? (Business terms: financial, operational, reputational)"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "3. WHAT did we do? (Immediate response actions)"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "4. WHAT's next? (Recommended follow-up actions)"
|
||||||
|
|
||||||
|
- type: annotation
|
||||||
|
content: "This framework helps analysts structure their thinking and ensures coverage of key points."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: analyst
|
||||||
|
content: "That's helpful. What about tone? My manager says my reports sound too alarmist."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "Good question. Compare these two approaches:"
|
||||||
|
codeBlock: |
|
||||||
|
ALARMIST:
|
||||||
|
"A catastrophic security breach nearly resulted in
|
||||||
|
massive financial losses. Urgent action is required
|
||||||
|
immediately to prevent future attacks."
|
||||||
|
|
||||||
|
MEASURED:
|
||||||
|
"A BEC attempt was detected and blocked before any
|
||||||
|
funds transferred. We recommend three process
|
||||||
|
improvements to reduce future risk."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "The measured version states facts, acknowledges what went right, and focuses on actionable improvements rather than fear."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: analyst
|
||||||
|
content: "I see the difference. Facts over fear, and focus on what we're doing about it."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: ai
|
||||||
|
content: "Exactly. Executives want to know the situation is under control and what resources you need. Alarmist language undermines confidence in the security team."
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
|||||||
|
# London Scam - Social Engineering Demo
|
||||||
|
#
|
||||||
|
# Based on real transcript from:
|
||||||
|
# https://rake.sh/2009/01/20/facebook-fraud-a-transcript/
|
||||||
|
#
|
||||||
|
# This demonstrates how attackers use compromised social media
|
||||||
|
# accounts to social engineer victims.
|
||||||
|
|
||||||
|
meta:
|
||||||
|
title: "London Scam - Social Engineering Demo"
|
||||||
|
description: "How attackers use compromised accounts to social engineer victims via chat"
|
||||||
|
theme: chat
|
||||||
|
articleUrl: "/bots-chatting-on-social-networks"
|
||||||
|
annotationLabel: "Security Note"
|
||||||
|
|
||||||
|
scenarios:
|
||||||
|
- id: main
|
||||||
|
title: "London Scam"
|
||||||
|
participants:
|
||||||
|
- id: attacker
|
||||||
|
label: "Matt (compromised account)"
|
||||||
|
role: left
|
||||||
|
- id: victim
|
||||||
|
label: "Rakesh"
|
||||||
|
role: right
|
||||||
|
steps:
|
||||||
|
- type: message
|
||||||
|
from: attacker
|
||||||
|
content: "hi. whats up?"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: victim
|
||||||
|
content: "Hi Matt. Everything OK?"
|
||||||
|
|
||||||
|
- type: annotation
|
||||||
|
content: "The attacker establishes rapport using the victim's friend's account. Notice how the response seems normal at first."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: attacker
|
||||||
|
content: "well, im really stuck here in london. i had to visit a resort here in london and i got robbed at the hotel im staying"
|
||||||
|
|
||||||
|
- type: annotation
|
||||||
|
content: "The scam begins. The attacker creates urgency with a crisis story. The 'London emergency' is one of the most common social engineering pretexts."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: victim
|
||||||
|
content: "Oh no! Are you OK? Did you call the police?"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: attacker
|
||||||
|
content: "yes i did, but they said it might take a while to get my stuff back. the problem is i need to pay my hotel bill and get a flight home"
|
||||||
|
|
||||||
|
- type: annotation
|
||||||
|
content: "The attacker builds on the crisis, establishing a clear financial need. They're setting up the ask."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: attacker
|
||||||
|
content: "i was wondering if you could help me out with some money? ill pay you back as soon as i get home"
|
||||||
|
|
||||||
|
- type: transition
|
||||||
|
content: "This is where the scam becomes explicit. The attacker asks for money."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: victim
|
||||||
|
content: "How much do you need? And how would I send it?"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: attacker
|
||||||
|
content: "about $1500 would cover the hotel and flight. you can send it through western union - its the fastest way"
|
||||||
|
|
||||||
|
- type: annotation
|
||||||
|
content: "Western Union is the preferred method because transfers are difficult to trace or reverse. This is a major red flag."
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: victim
|
||||||
|
content: "That's a lot of money. Can't you call the embassy or your bank?"
|
||||||
|
|
||||||
|
- type: message
|
||||||
|
from: attacker
|
||||||
|
content: "i tried but they said it would take days. please, i really need your help. i promise ill pay you back"
|
||||||
|
|
||||||
|
- type: annotation
|
||||||
|
content: "The attacker increases pressure and appeals to friendship. Real friends would understand delays - scammers push urgency."
|
||||||
|
|
||||||
|
- type: transition
|
||||||
|
content: "Red flags in this conversation: urgency, money request, Western Union, resistance to alternatives"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "conversation-replay",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Create animated conversation demos from YAML for security awareness, IR training, and communication skills education",
|
||||||
|
"module": "src/cli.ts",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"conversation-replay": "./src/cli.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun run src/cli.ts build",
|
||||||
|
"validate": "bun run src/cli.ts validate",
|
||||||
|
"example": "bun run src/cli.ts build examples/london-scam.yaml -o examples/london-scam.html"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"conversation",
|
||||||
|
"demo",
|
||||||
|
"animation",
|
||||||
|
"security-awareness",
|
||||||
|
"training",
|
||||||
|
"chat",
|
||||||
|
"replay"
|
||||||
|
],
|
||||||
|
"author": "Lenny Zeltser",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Conversation Replay - CLI
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* conversation-replay build <scenario.yaml> -o <output.html>
|
||||||
|
* conversation-replay validate <scenario.yaml>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseArgs } from 'util';
|
||||||
|
import { loadDemo, ParseError } from './parser';
|
||||||
|
import { buildDemo } from './generator';
|
||||||
|
import type { Theme } from './types';
|
||||||
|
|
||||||
|
const HELP = `
|
||||||
|
conversation-replay - Create animated conversation demos from YAML
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
conversation-replay build <scenario.yaml> -o <output.html> [options]
|
||||||
|
conversation-replay validate <scenario.yaml>
|
||||||
|
conversation-replay --help
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
build Generate HTML from a scenario file
|
||||||
|
validate Check a scenario file for errors
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-o, --output <path> Output HTML file path (required for build)
|
||||||
|
--theme <theme> Override theme (chat, email, slack, terminal, generic)
|
||||||
|
--no-header Exclude the demo header
|
||||||
|
-h, --help Show this help message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
conversation-replay build demo.yaml -o demo.html
|
||||||
|
conversation-replay build demo.yaml -o demo.html --theme email
|
||||||
|
conversation-replay validate demo.yaml
|
||||||
|
`;
|
||||||
|
|
||||||
|
const VALID_THEMES = ['chat', 'email', 'slack', 'terminal', 'generic'];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { values, positionals } = parseArgs({
|
||||||
|
args: Bun.argv.slice(2),
|
||||||
|
options: {
|
||||||
|
output: { type: 'string', short: 'o' },
|
||||||
|
theme: { type: 'string' },
|
||||||
|
'no-header': { type: 'boolean' },
|
||||||
|
help: { type: 'boolean', short: 'h' },
|
||||||
|
},
|
||||||
|
allowPositionals: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (values.help || positionals.length === 0) {
|
||||||
|
console.log(HELP);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = positionals[0];
|
||||||
|
const inputFile = positionals[1];
|
||||||
|
|
||||||
|
if (!inputFile) {
|
||||||
|
console.error('Error: No input file specified\n');
|
||||||
|
console.log(HELP);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate theme if provided
|
||||||
|
if (values.theme && !VALID_THEMES.includes(values.theme)) {
|
||||||
|
console.error(`Error: Invalid theme "${values.theme}"`);
|
||||||
|
console.error(`Valid themes: ${VALID_THEMES.join(', ')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (command) {
|
||||||
|
case 'build':
|
||||||
|
await handleBuild(inputFile, values);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'validate':
|
||||||
|
await handleValidate(inputFile);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.error(`Error: Unknown command "${command}"\n`);
|
||||||
|
console.log(HELP);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ParseError) {
|
||||||
|
console.error(`Parse Error: ${error.message}`);
|
||||||
|
if (error.path) {
|
||||||
|
console.error(` File: ${error.path}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBuild(
|
||||||
|
inputFile: string,
|
||||||
|
options: { output?: string; theme?: string; 'no-header'?: boolean }
|
||||||
|
) {
|
||||||
|
if (!options.output) {
|
||||||
|
console.error('Error: Output file required (-o <path>)\n');
|
||||||
|
console.log('Usage: conversation-replay build <input.yaml> -o <output.html>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Loading ${inputFile}...`);
|
||||||
|
const demo = await loadDemo(inputFile);
|
||||||
|
|
||||||
|
console.log(`Building ${options.output}...`);
|
||||||
|
await buildDemo(demo, options.output, {
|
||||||
|
theme: options.theme as Theme | undefined,
|
||||||
|
includeHeader: !options['no-header'],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Done! Generated ${options.output}`);
|
||||||
|
console.log(` Title: ${demo.meta.title}`);
|
||||||
|
console.log(` Scenarios: ${demo.scenarios.length}`);
|
||||||
|
for (const scenario of demo.scenarios) {
|
||||||
|
const stepCount = scenario.steps.length;
|
||||||
|
const participants = scenario.participants.map(p => p.label).join(', ');
|
||||||
|
console.log(` - ${scenario.title}: ${stepCount} steps (${participants})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleValidate(inputFile: string) {
|
||||||
|
console.log(`Validating ${inputFile}...`);
|
||||||
|
const demo = await loadDemo(inputFile);
|
||||||
|
|
||||||
|
console.log('Valid!');
|
||||||
|
console.log(` Title: ${demo.meta.title}`);
|
||||||
|
console.log(` Theme: ${demo.meta.theme ?? 'chat (default)'}`);
|
||||||
|
console.log(` Scenarios: ${demo.scenarios.length}`);
|
||||||
|
|
||||||
|
for (const scenario of demo.scenarios) {
|
||||||
|
console.log(`\n Scenario: ${scenario.title} (${scenario.id})`);
|
||||||
|
console.log(` Participants:`);
|
||||||
|
for (const p of scenario.participants) {
|
||||||
|
console.log(` - ${p.label} (${p.id}, ${p.role})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count step types
|
||||||
|
const counts = { message: 0, annotation: 0, transition: 0 };
|
||||||
|
for (const step of scenario.steps) {
|
||||||
|
if (step.type === 'message') counts.message++;
|
||||||
|
else if (step.type === 'annotation') counts.annotation++;
|
||||||
|
else if (step.type === 'transition') counts.transition++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Steps: ${scenario.steps.length}`);
|
||||||
|
console.log(` - Messages: ${counts.message}`);
|
||||||
|
if (counts.annotation > 0) console.log(` - Annotations: ${counts.annotation}`);
|
||||||
|
if (counts.transition > 0) console.log(` - Transitions: ${counts.transition}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error('Unexpected error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+1749
File diff suppressed because it is too large
Load Diff
+430
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* Conversation Replay - YAML Parser
|
||||||
|
*
|
||||||
|
* Parses and validates scenario YAML files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parse } from 'yaml';
|
||||||
|
import type { Demo, DemoMeta, Scenario, Step, Participant, Theme, ColorConfig, TimerStyle, CornerStyle, SpeedConfig } from './types';
|
||||||
|
|
||||||
|
const VALID_THEMES: Theme[] = ['chat', 'email', 'slack', 'terminal', 'generic'];
|
||||||
|
const VALID_TIMER_STYLES: TimerStyle[] = ['bar', 'circle'];
|
||||||
|
const VALID_CORNER_STYLES: CornerStyle[] = ['rounded', 'straight'];
|
||||||
|
|
||||||
|
export class ParseError extends Error {
|
||||||
|
constructor(message: string, public path?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ParseError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse YAML content into a Demo object
|
||||||
|
*/
|
||||||
|
export function parseDemo(yamlContent: string, filePath?: string): Demo {
|
||||||
|
let raw: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
raw = parse(yamlContent);
|
||||||
|
} catch (e) {
|
||||||
|
throw new ParseError(`Invalid YAML syntax: ${(e as Error).message}`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
throw new ParseError('Demo must be an object', filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!Array.isArray(data.scenarios)) {
|
||||||
|
throw new ParseError('Demo must have a "scenarios" array', filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = validateDemoMeta(data.meta, filePath);
|
||||||
|
const scenarios = validateScenarios(data.scenarios as unknown[], filePath);
|
||||||
|
|
||||||
|
return { meta, scenarios };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDemoMeta(raw: unknown, filePath?: string): DemoMeta {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
throw new ParseError('meta section is required', filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof meta.title !== 'string' || !meta.title.trim()) {
|
||||||
|
throw new ParseError('meta.title is required and must be a non-empty string', filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: DemoMeta = {
|
||||||
|
title: meta.title.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (meta.description !== undefined) {
|
||||||
|
if (typeof meta.description !== 'string') {
|
||||||
|
throw new ParseError('meta.description must be a string', filePath);
|
||||||
|
}
|
||||||
|
result.description = meta.description.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.theme !== undefined) {
|
||||||
|
if (!VALID_THEMES.includes(meta.theme as Theme)) {
|
||||||
|
throw new ParseError(
|
||||||
|
`meta.theme must be one of: ${VALID_THEMES.join(', ')}`,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.theme = meta.theme as Theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.articleUrl !== undefined) {
|
||||||
|
if (typeof meta.articleUrl !== 'string') {
|
||||||
|
throw new ParseError('meta.articleUrl must be a string', filePath);
|
||||||
|
}
|
||||||
|
result.articleUrl = meta.articleUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.hideHeaderInIframe !== undefined) {
|
||||||
|
result.hideHeaderInIframe = Boolean(meta.hideHeaderInIframe);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.autoAdvance !== undefined) {
|
||||||
|
result.autoAdvance = Boolean(meta.autoAdvance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.annotationLabel !== undefined) {
|
||||||
|
if (typeof meta.annotationLabel !== 'string') {
|
||||||
|
throw new ParseError('meta.annotationLabel must be a string', filePath);
|
||||||
|
}
|
||||||
|
result.annotationLabel = meta.annotationLabel.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.colors !== undefined) {
|
||||||
|
result.colors = validateColors(meta.colors, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.timerStyle !== undefined) {
|
||||||
|
if (!VALID_TIMER_STYLES.includes(meta.timerStyle as TimerStyle)) {
|
||||||
|
throw new ParseError(
|
||||||
|
`meta.timerStyle must be one of: ${VALID_TIMER_STYLES.join(', ')}`,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.timerStyle = meta.timerStyle as TimerStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.cornerStyle !== undefined) {
|
||||||
|
if (!VALID_CORNER_STYLES.includes(meta.cornerStyle as CornerStyle)) {
|
||||||
|
throw new ParseError(
|
||||||
|
`meta.cornerStyle must be one of: ${VALID_CORNER_STYLES.join(', ')}`,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.cornerStyle = meta.cornerStyle as CornerStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.speed !== undefined) {
|
||||||
|
result.speed = validateSpeedConfig(meta.speed, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSpeedConfig(raw: unknown, filePath?: string): SpeedConfig {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
throw new ParseError('meta.speed must be an object', filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const speed = raw as Record<string, unknown>;
|
||||||
|
const result: SpeedConfig = {};
|
||||||
|
|
||||||
|
const numericFields: (keyof SpeedConfig)[] = [
|
||||||
|
'minDelay', 'maxDelay', 'msPerWord', 'annotationMultiplier', 'upNextDelay'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of numericFields) {
|
||||||
|
if (speed[field] !== undefined) {
|
||||||
|
const value = speed[field];
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||||
|
throw new ParseError(
|
||||||
|
`meta.speed.${field} must be a positive number`,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result[field] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate logical constraints
|
||||||
|
if (result.minDelay !== undefined && result.maxDelay !== undefined) {
|
||||||
|
if (result.minDelay > result.maxDelay) {
|
||||||
|
throw new ParseError(
|
||||||
|
'meta.speed.minDelay cannot be greater than meta.speed.maxDelay',
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a CSS color value to prevent CSS injection attacks.
|
||||||
|
* Allows: hex colors, rgb/rgba/hsl/hsla, named colors, CSS variables
|
||||||
|
* Blocks: semicolons, braces, and other CSS injection attempts
|
||||||
|
*/
|
||||||
|
function isValidCssColor(value: string): boolean {
|
||||||
|
// Block obvious injection attempts
|
||||||
|
if (/[;{}]/.test(value)) return false;
|
||||||
|
if (/url\s*\(/i.test(value)) return false;
|
||||||
|
if (/expression\s*\(/i.test(value)) return false;
|
||||||
|
if (/javascript:/i.test(value)) return false;
|
||||||
|
|
||||||
|
// Allow common color formats
|
||||||
|
const validPatterns = [
|
||||||
|
/^#[0-9a-f]{3,8}$/i, // hex colors
|
||||||
|
/^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/i, // rgb()
|
||||||
|
/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)$/i, // rgba()
|
||||||
|
/^hsl\(\s*\d+\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*\)$/i, // hsl()
|
||||||
|
/^hsla\(\s*\d+\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*,\s*[\d.]+\s*\)$/i, // hsla()
|
||||||
|
/^var\(--[a-z0-9-]+\)$/i, // CSS variables
|
||||||
|
/^[a-z]+$/i, // named colors (red, blue, etc.)
|
||||||
|
/^transparent$/i,
|
||||||
|
/^inherit$/i,
|
||||||
|
/^currentColor$/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
return validPatterns.some(pattern => pattern.test(value.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateColors(raw: unknown, filePath?: string): ColorConfig {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
throw new ParseError('meta.colors must be an object', filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = raw as Record<string, unknown>;
|
||||||
|
const result: ColorConfig = {};
|
||||||
|
|
||||||
|
const colorFields: (keyof ColorConfig)[] = [
|
||||||
|
'accent', 'pageBg', 'canvasBg', 'leftBg', 'leftBorder',
|
||||||
|
'rightBg', 'rightBorder', 'tabInactiveColor'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of colorFields) {
|
||||||
|
if (colors[field] !== undefined) {
|
||||||
|
if (typeof colors[field] !== 'string') {
|
||||||
|
throw new ParseError(`meta.colors.${field} must be a string`, filePath);
|
||||||
|
}
|
||||||
|
const colorValue = colors[field] as string;
|
||||||
|
if (!isValidCssColor(colorValue)) {
|
||||||
|
throw new ParseError(
|
||||||
|
`meta.colors.${field} contains invalid CSS color value: "${colorValue}"`,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result[field] = colorValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateScenarios(raw: unknown[], filePath?: string): Scenario[] {
|
||||||
|
if (raw.length === 0) {
|
||||||
|
throw new ParseError('at least one scenario is required', filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
return raw.map((s, i) => {
|
||||||
|
if (!s || typeof s !== 'object') {
|
||||||
|
throw new ParseError(`scenarios[${i}] must be an object`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenario = s as Record<string, unknown>;
|
||||||
|
const prefix = `scenarios[${i}]`;
|
||||||
|
|
||||||
|
// Validate id
|
||||||
|
if (typeof scenario.id !== 'string' || !scenario.id.trim()) {
|
||||||
|
throw new ParseError(`${prefix}.id is required`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = scenario.id.trim();
|
||||||
|
if (ids.has(id)) {
|
||||||
|
throw new ParseError(`duplicate scenario id: ${id}`, filePath);
|
||||||
|
}
|
||||||
|
ids.add(id);
|
||||||
|
|
||||||
|
// Validate title
|
||||||
|
if (typeof scenario.title !== 'string' || !scenario.title.trim()) {
|
||||||
|
throw new ParseError(`${prefix}.title is required`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate participants
|
||||||
|
const participants = validateParticipants(scenario.participants, prefix, filePath);
|
||||||
|
|
||||||
|
// Validate steps
|
||||||
|
const steps = validateSteps(scenario.steps, participants, prefix, filePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: scenario.title.trim(),
|
||||||
|
participants,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateParticipants(
|
||||||
|
raw: unknown,
|
||||||
|
prefix: string,
|
||||||
|
filePath?: string
|
||||||
|
): Participant[] {
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
throw new ParseError(`${prefix}.participants must be an array`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.length === 0) {
|
||||||
|
throw new ParseError(`${prefix}: at least one participant is required`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
return raw.map((p, i) => {
|
||||||
|
if (!p || typeof p !== 'object') {
|
||||||
|
throw new ParseError(`${prefix}.participants[${i}] must be an object`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const participant = p as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof participant.id !== 'string' || !participant.id.trim()) {
|
||||||
|
throw new ParseError(`${prefix}.participants[${i}].id is required`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = participant.id.trim();
|
||||||
|
if (ids.has(id)) {
|
||||||
|
throw new ParseError(`${prefix}: duplicate participant id: ${id}`, filePath);
|
||||||
|
}
|
||||||
|
ids.add(id);
|
||||||
|
|
||||||
|
if (typeof participant.label !== 'string' || !participant.label.trim()) {
|
||||||
|
throw new ParseError(`${prefix}.participants[${i}].label is required`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = participant.role ?? 'left';
|
||||||
|
if (role !== 'left' && role !== 'right') {
|
||||||
|
throw new ParseError(
|
||||||
|
`${prefix}.participants[${i}].role must be 'left' or 'right'`,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: participant.label.trim(),
|
||||||
|
role: role as 'left' | 'right',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSteps(
|
||||||
|
raw: unknown,
|
||||||
|
participants: Participant[],
|
||||||
|
prefix: string,
|
||||||
|
filePath?: string
|
||||||
|
): Step[] {
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
throw new ParseError(`${prefix}.steps must be an array`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.length === 0) {
|
||||||
|
throw new ParseError(`${prefix}: at least one step is required`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantIds = new Set(participants.map(p => p.id));
|
||||||
|
|
||||||
|
return raw.map((s, i) => {
|
||||||
|
if (!s || typeof s !== 'object') {
|
||||||
|
throw new ParseError(`${prefix}.steps[${i}] must be an object`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = s as Record<string, unknown>;
|
||||||
|
const type = step.type ?? 'message';
|
||||||
|
const stepPrefix = `${prefix}.steps[${i}]`;
|
||||||
|
|
||||||
|
if (type === 'annotation') {
|
||||||
|
if (typeof step.content !== 'string' || !step.content.trim()) {
|
||||||
|
throw new ParseError(`${stepPrefix}.content is required for annotation`, filePath);
|
||||||
|
}
|
||||||
|
return { type: 'annotation' as const, content: step.content.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'transition') {
|
||||||
|
if (typeof step.content !== 'string' || !step.content.trim()) {
|
||||||
|
throw new ParseError(`${stepPrefix}.content is required for transition`, filePath);
|
||||||
|
}
|
||||||
|
return { type: 'transition' as const, content: step.content.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'message') {
|
||||||
|
if (typeof step.from !== 'string' || !step.from.trim()) {
|
||||||
|
throw new ParseError(`${stepPrefix}.from is required for message`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = step.from.trim();
|
||||||
|
if (!participantIds.has(from)) {
|
||||||
|
throw new ParseError(
|
||||||
|
`${stepPrefix}.from "${from}" is not a valid participant id`,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof step.content !== 'string' || !step.content.trim()) {
|
||||||
|
throw new ParseError(`${stepPrefix}.content is required for message`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Step = {
|
||||||
|
type: 'message',
|
||||||
|
from,
|
||||||
|
content: step.content.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step.codeBlock !== undefined) {
|
||||||
|
if (typeof step.codeBlock !== 'string') {
|
||||||
|
throw new ParseError(`${stepPrefix}.codeBlock must be a string`, filePath);
|
||||||
|
}
|
||||||
|
(result as any).codeBlock = step.codeBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.footnote !== undefined) {
|
||||||
|
if (typeof step.footnote !== 'string') {
|
||||||
|
throw new ParseError(`${stepPrefix}.footnote must be a string`, filePath);
|
||||||
|
}
|
||||||
|
(result as any).footnote = step.footnote.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ParseError(
|
||||||
|
`${stepPrefix}.type must be 'message', 'annotation', or 'transition'`,
|
||||||
|
filePath
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and parse a demo from a file path
|
||||||
|
*/
|
||||||
|
export async function loadDemo(filePath: string): Promise<Demo> {
|
||||||
|
const file = Bun.file(filePath);
|
||||||
|
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
throw new ParseError(`File not found: ${filePath}`, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await file.text();
|
||||||
|
return parseDemo(content, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Conversation Replay - Type Definitions
|
||||||
|
*
|
||||||
|
* Defines the schema for YAML scenario files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Theme = 'chat' | 'email' | 'slack' | 'terminal' | 'generic';
|
||||||
|
export type ParticipantRole = 'left' | 'right';
|
||||||
|
export type StepType = 'message' | 'annotation' | 'transition';
|
||||||
|
export type TimerStyle = 'bar' | 'circle';
|
||||||
|
export type CornerStyle = 'rounded' | 'straight';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speed/timing configuration for frame progression
|
||||||
|
*/
|
||||||
|
export interface SpeedConfig {
|
||||||
|
/** Minimum delay between frames in ms (default: 3000) */
|
||||||
|
minDelay?: number;
|
||||||
|
/** Maximum delay between frames in ms (default: 8000) */
|
||||||
|
maxDelay?: number;
|
||||||
|
/** Milliseconds per word for reading time calculation (default: 200) */
|
||||||
|
msPerWord?: number;
|
||||||
|
/** Multiplier for annotation display time (default: 1.15) */
|
||||||
|
annotationMultiplier?: number;
|
||||||
|
/** Delay to show "Up Next" before transitioning in ms (default: 2500) */
|
||||||
|
upNextDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Participant {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
role: ParticipantRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageStep {
|
||||||
|
type: 'message';
|
||||||
|
from: string; // participant id
|
||||||
|
content: string;
|
||||||
|
codeBlock?: string;
|
||||||
|
footnote?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnotationStep {
|
||||||
|
type: 'annotation';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransitionStep {
|
||||||
|
type: 'transition';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Step = MessageStep | AnnotationStep | TransitionStep;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single scenario (one tab in a multi-scenario demo)
|
||||||
|
*/
|
||||||
|
export interface Scenario {
|
||||||
|
/** Unique identifier for this scenario (used in tabs) */
|
||||||
|
id: string;
|
||||||
|
/** Display title shown in tab */
|
||||||
|
title: string;
|
||||||
|
/** Participants in this scenario */
|
||||||
|
participants: Participant[];
|
||||||
|
/** Steps in this scenario */
|
||||||
|
steps: Step[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom color configuration
|
||||||
|
*/
|
||||||
|
export interface ColorConfig {
|
||||||
|
/** Primary accent color (used for buttons, links) */
|
||||||
|
accent?: string;
|
||||||
|
/** Page background color (when not embedded) */
|
||||||
|
pageBg?: string;
|
||||||
|
/** Chat canvas/container background color */
|
||||||
|
canvasBg?: string;
|
||||||
|
/** Left participant message background */
|
||||||
|
leftBg?: string;
|
||||||
|
/** Left participant message border */
|
||||||
|
leftBorder?: string;
|
||||||
|
/** Right participant message background */
|
||||||
|
rightBg?: string;
|
||||||
|
/** Right participant message border */
|
||||||
|
rightBorder?: string;
|
||||||
|
/** Inactive tab text color */
|
||||||
|
tabInactiveColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overall demo metadata
|
||||||
|
*/
|
||||||
|
export interface DemoMeta {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
theme?: Theme;
|
||||||
|
/** Link back to related article */
|
||||||
|
articleUrl?: string;
|
||||||
|
/** Whether to auto-hide header when embedded in iframe */
|
||||||
|
hideHeaderInIframe?: boolean;
|
||||||
|
/** Whether to auto-advance to next scenario when one completes */
|
||||||
|
autoAdvance?: boolean;
|
||||||
|
/** Label for annotation steps (default: "Behind the Scenes") */
|
||||||
|
annotationLabel?: string;
|
||||||
|
/** Custom colors */
|
||||||
|
colors?: ColorConfig;
|
||||||
|
/** Timer display style: 'bar' (horizontal line) or 'circle' (circular countdown). Default: 'circle' */
|
||||||
|
timerStyle?: TimerStyle;
|
||||||
|
/** Corner style for chat container and bubbles. Default: 'rounded' */
|
||||||
|
cornerStyle?: CornerStyle;
|
||||||
|
/** Speed/timing configuration for frame progression */
|
||||||
|
speed?: SpeedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A complete demo with one or more scenarios
|
||||||
|
*/
|
||||||
|
export interface Demo {
|
||||||
|
meta: DemoMeta;
|
||||||
|
scenarios: Scenario[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildOptions {
|
||||||
|
outputPath: string;
|
||||||
|
theme?: Theme;
|
||||||
|
/** Include demo header with title/description */
|
||||||
|
includeHeader?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user