From c437f04e0becf195c40d6cf890dba7487183d12a Mon Sep 17 00:00:00 2001 From: lennyzeltser Date: Wed, 14 Jan 2026 17:55:56 -0500 Subject: [PATCH] Initial commit --- .../use-bun-instead-of-node-vite-npm-pnpm.mdc | 1 + .gitignore | 34 + AGENTS.md | 10 + LICENSE.md | 21 + README.md | 388 ++++ bun.lock | 31 + example.html | 1438 ++++++++++++++ example.yaml | 32 + examples/README.md | 47 + examples/ir-report.html | 1512 ++++++++++++++ examples/ir-report.yaml | 190 ++ examples/london-scam.html | 1438 ++++++++++++++ examples/london-scam.yaml | 86 + package.json | 35 + src/cli.ts | 165 ++ src/generator.ts | 1749 +++++++++++++++++ src/parser.ts | 430 ++++ src/types.ts | 129 ++ tsconfig.json | 29 + 19 files changed, 7765 insertions(+) create mode 120000 .cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 example.html create mode 100644 example.yaml create mode 100644 examples/README.md create mode 100644 examples/ir-report.html create mode 100644 examples/ir-report.yaml create mode 100644 examples/london-scam.html create mode 100644 examples/london-scam.yaml create mode 100644 package.json create mode 100644 src/cli.ts create mode 100644 src/generator.ts create mode 100644 src/parser.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc new file mode 120000 index 0000000..6100270 --- /dev/null +++ b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc @@ -0,0 +1 @@ +../../CLAUDE.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1514851 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7e9a668 --- /dev/null +++ b/LICENSE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9acc83b --- /dev/null +++ b/README.md @@ -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 + +``` + +### 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 -o [options] +conversation-replay validate + +Options: + -o, --output Output HTML file (required for build) + --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 ` + + +
+ +
+

Example Conversation

+

A test scenario to evaluate the UI.

+
+ + +
+
+
+
+ +
+
+ + + +
+
+
+ +
+ + + + +
+ + +
+ + + + \ No newline at end of file diff --git a/example.yaml b/example.yaml new file mode 100644 index 0000000..153fd84 --- /dev/null +++ b/example.yaml @@ -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." diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..22ca7dc --- /dev/null +++ b/examples/README.md @@ -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. diff --git a/examples/ir-report.html b/examples/ir-report.html new file mode 100644 index 0000000..5e6c1ca --- /dev/null +++ b/examples/ir-report.html @@ -0,0 +1,1512 @@ + + + + + + AI-Assisted IR Report Writing + + + +
+ +
+

AI-Assisted IR Report Writing

+

Using AI tools to create, review, and improve incident response documentation

+
+ + + + +
+
+
+
+ +
+
+ + + +
+
+
+ +
+ + + + +
+ + +
+ + + + \ No newline at end of file diff --git a/examples/ir-report.yaml b/examples/ir-report.yaml new file mode 100644 index 0000000..08ee582 --- /dev/null +++ b/examples/ir-report.yaml @@ -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." diff --git a/examples/london-scam.html b/examples/london-scam.html new file mode 100644 index 0000000..acd263a --- /dev/null +++ b/examples/london-scam.html @@ -0,0 +1,1438 @@ + + + + + + London Scam - Social Engineering Demo + + + +
+ +
+

London Scam - Social Engineering Demo

+

How attackers use compromised accounts to social engineer victims via chat

+
+ + +
+
+
+
+ +
+
+ + + +
+
+
+ +
+ + + + +
+ + +
+ + + + \ No newline at end of file diff --git a/examples/london-scam.yaml b/examples/london-scam.yaml new file mode 100644 index 0000000..ebe8199 --- /dev/null +++ b/examples/london-scam.yaml @@ -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" diff --git a/package.json b/package.json new file mode 100644 index 0000000..68431be --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..fc7fa86 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,165 @@ +#!/usr/bin/env bun +/** + * Conversation Replay - CLI + * + * Usage: + * conversation-replay build -o + * conversation-replay validate + */ + +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 -o [options] + conversation-replay validate + conversation-replay --help + +Commands: + build Generate HTML from a scenario file + validate Check a scenario file for errors + +Options: + -o, --output Output HTML file path (required for build) + --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 )\n'); + console.log('Usage: conversation-replay build -o '); + 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); +}); diff --git a/src/generator.ts b/src/generator.ts new file mode 100644 index 0000000..5e384cb --- /dev/null +++ b/src/generator.ts @@ -0,0 +1,1749 @@ +/** + * Conversation Replay - HTML Generator + * + * Generates self-contained HTML files from demo definitions. + * Supports multi-scenario demos with tabs. + * + * Security: All dynamic content is rendered using safe DOM methods (textContent, + * createElement) - no innerHTML with untrusted content. + */ + +import type { Demo, Scenario, Step, Participant, Theme, BuildOptions, ColorConfig, TimerStyle, CornerStyle, SpeedConfig } from './types'; + +/** + * Escape HTML special characters for static content only + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Validate URL is safe (not javascript:, data:, etc.) + */ +function isSafeUrl(url: string): boolean { + const trimmed = url.trim().toLowerCase(); + // Block dangerous protocols + if (trimmed.startsWith('javascript:')) return false; + if (trimmed.startsWith('data:')) return false; + if (trimmed.startsWith('vbscript:')) return false; + // Allow http, https, mailto, tel, and relative URLs + return true; +} + +/** + * Parse markdown-style links in text: [text](url) -> text + * Text is HTML-escaped first, then links are converted. + * Dangerous URLs (javascript:, data:, etc.) are rendered as plain text. + */ +function parseMarkdownLinks(text: string): string { + return escapeHtml(text).replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (match, linkText, url) => { + if (isSafeUrl(url)) { + return `${linkText}`; + } + // Render dangerous URLs as plain text (already escaped) + return match; + } + ); +} + +/** + * Convert scenario steps to JavaScript object notation + */ +function stepsToJs(scenario: Scenario): string { + const participantMap = new Map( + scenario.participants.map(p => [p.id, p]) + ); + + const jsSteps = scenario.steps.map(step => { + if (step.type === 'annotation') { + return `{ type: "annotation", plainText: ${JSON.stringify(step.content)} }`; + } + + if (step.type === 'transition') { + return `{ type: "transition", plainText: ${JSON.stringify(step.content)} }`; + } + + // Message step + const participant = participantMap.get(step.from)!; + const role = participant.role === 'left' ? 'user' : 'ai'; + + let js = `{ type: ${JSON.stringify(role)}, plainText: ${JSON.stringify(step.content)}`; + + if (step.codeBlock) { + js += `, codeBlock: ${JSON.stringify(step.codeBlock)}`; + } + + if (step.footnote) { + js += `, footnote: ${JSON.stringify(step.footnote)}`; + } + + js += ' }'; + return js; + }); + + return `[${jsSteps.join(', ')}]`; +} + +/** + * Get participant labels for a scenario + */ +function getParticipantLabels(scenario: Scenario): { left: string; right: string } { + const left = scenario.participants.find(p => p.role === 'left'); + const right = scenario.participants.find(p => p.role === 'right'); + + return { + left: left?.label ?? 'User', + right: right?.label ?? 'Assistant', + }; +} + +/** + * Generate scenarios JavaScript object + */ +function generateScenariosJs(demo: Demo): string { + const scenarioEntries = demo.scenarios.map(scenario => { + const labels = getParticipantLabels(scenario); + return ` + ${JSON.stringify(scenario.id)}: { + title: ${JSON.stringify(scenario.title)}, + labels: { user: ${JSON.stringify(labels.left)}, ai: ${JSON.stringify(labels.right)} }, + steps: ${stepsToJs(scenario)} + }`; + }); + + return `{${scenarioEntries.join(',')}\n }`; +} + +/** + * Generate custom color CSS overrides + */ +function generateColorOverrides(colors?: ColorConfig): string { + if (!colors) return ''; + + const overrides: string[] = []; + + if (colors.accent) { + overrides.push(`--accent: ${colors.accent};`); + } + if (colors.pageBg) { + overrides.push(`--bg-primary: ${colors.pageBg};`); + } + if (colors.canvasBg) { + overrides.push(`--bg-chat: ${colors.canvasBg};`); + } + if (colors.leftBg) { + overrides.push(`--user-bg: ${colors.leftBg};`); + } + if (colors.leftBorder) { + overrides.push(`--user-border: ${colors.leftBorder};`); + } + if (colors.rightBg) { + overrides.push(`--ai-bg: ${colors.rightBg};`); + } + if (colors.rightBorder) { + overrides.push(`--ai-border: ${colors.rightBorder};`); + } + if (colors.tabInactiveColor) { + overrides.push(`--tab-inactive-color: ${colors.tabInactiveColor};`); + } + + if (overrides.length === 0) return ''; + + return ` + :root { + ${overrides.join('\n ')} + } + `; +} + +/** + * Generate the CSS for the demo player + */ +function generateCss(theme: Theme, hasMultipleScenarios: boolean, colors?: ColorConfig, cornerStyle?: CornerStyle): string { + const colorOverrides = generateColorOverrides(colors); + const radius = cornerStyle === 'straight' ? '0' : '8px'; + const radiusLg = cornerStyle === 'straight' ? '0' : '12px'; + + const tabCss = hasMultipleScenarios ? ` + /* Tabs - browser-style integrated with canvas */ + .tabs { + display: flex; + gap: 0; + margin-left: 12px; + margin-bottom: -1px; + flex-wrap: wrap; + position: relative; + z-index: 2; + } + + .tab { + padding: 10px 20px; + border: 1px solid transparent; + border-bottom: none; + background: transparent; + color: var(--tab-inactive-color, var(--text-muted)); + border-radius: var(--radius) var(--radius) 0 0; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s; + opacity: 0.75; + } + + .tab:hover { + opacity: 0.9; + color: var(--text-primary); + } + + .tab.active { + background: var(--bg-chat); + color: var(--text-primary); + border-color: var(--border-color); + opacity: 1; + position: relative; + } + + .tab.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: var(--bg-chat); + } + + @media (max-width: 600px) { + .tab { + padding: 10px 14px; + font-size: 12px; + flex: 1; + text-align: center; + min-width: 0; + } + } + ` : ''; + + return ` + *, *::before, *::after { + box-sizing: border-box; + } + + :root { + --bg-primary: #f6f7f9; + --bg-secondary: #ffffff; + --bg-chat: #ffffff; + --text-primary: #060426; + --text-secondary: #666666; + --text-muted: #666666; + --accent: #1a45bc; + --accent-light: #e0e7f8; + --user-bg: #e0f2fe; + --user-border: #7dd3fc; + --ai-bg: #f0fdf4; + --ai-border: #86efac; + --annotation-bg: transparent; + --annotation-border: #94a3b8; + --annotation-text: #64748b; + --transition-bg: #f3e8ff; + --transition-border: #c084fc; + --border-color: #e0e7f8; + --radius: ${radius}; + --radius-lg: ${radiusLg}; + } + + @media (prefers-color-scheme: dark) { + :root { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-chat: #161b22; + --text-primary: #c9d1d9; + --text-secondary: #8b949e; + --text-muted: #8b949e; + --accent: #58a6ff; + --accent-light: #1f2937; + --user-bg: #0c4a6e; + --user-border: #0284c7; + --ai-bg: #14532d; + --ai-border: #22c55e; + --annotation-bg: transparent; + --annotation-border: #475569; + --annotation-text: #94a3b8; + --transition-bg: #4c1d95; + --transition-border: #a855f7; + --border-color: #30363d; + } + } + + /* Dark mode via data-theme attribute (for iframe sync with parent) */ + :root[data-theme="dark"] { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-chat: #161b22; + --text-primary: #c9d1d9; + --text-secondary: #8b949e; + --text-muted: #8b949e; + --accent: #58a6ff; + --accent-light: #1f2937; + --user-bg: #0c4a6e; + --user-border: #0284c7; + --ai-bg: #14532d; + --ai-border: #22c55e; + --annotation-bg: transparent; + --annotation-border: #475569; + --annotation-text: #94a3b8; + --transition-bg: #4c1d95; + --transition-border: #a855f7; + --border-color: #30363d; + } + + :root[data-theme="dark"] .play-overlay { + background: rgba(0, 0, 0, 0.5); + } + + :root[data-theme="dark"] .play-overlay-icon { + background: rgba(255, 255, 255, 0.9); + } + + ${colorOverrides} + + body { + margin: 0; + padding: 16px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + background: var(--bg-primary); + color: var(--text-primary); + } + + .demo-container { + max-width: 800px; + margin: 0 auto; + } + + .demo-header { + margin-bottom: 16px; + padding-bottom: 12px; + } + + .demo-header.hidden { + display: none; + } + + .demo-title { + font-size: 18px; + font-weight: 600; + margin: 0 0 8px 0; + } + + .demo-title-link { + color: inherit; + text-decoration: none; + } + + .demo-title-link:hover { + text-decoration: underline; + } + + .demo-description { + font-size: 14px; + color: var(--text-secondary); + margin: 0; + line-height: 1.5; + } + + .demo-description a { + color: var(--accent); + } + + ${tabCss} + + .chat-wrapper { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + + .chat-container { + position: relative; + background: var(--bg-chat); + border-radius: var(--radius-lg); + border: 1px solid var(--border-color); + padding: 16px; + flex: 1; + min-height: 350px; + max-height: var(--chat-max-height, 500px); + overflow-y: auto; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + transition: opacity 0.3s ease; + } + + .chat-container.fading { + opacity: 0; + } + + /* Standalone mode: chat fills available space, controls stay visible */ + body:not(.in-iframe) .demo-container { + height: calc(100vh - 32px); + display: flex; + flex-direction: column; + } + + body:not(.in-iframe) .chat-container { + /* Fills remaining space after header/tabs/controls */ + --chat-max-height: none; + flex: 1; + min-height: 200px; + } + + /* Iframe mode: fill viewport, transparent background */ + html.in-iframe, + body.in-iframe { + height: 100%; + background: transparent; + padding: 0; + margin: 0; + overflow: hidden; + } + + body.in-iframe .demo-container { + height: 100%; + display: flex; + flex-direction: column; + background: transparent; + padding: 0; + } + + body.in-iframe .tabs { + flex-shrink: 0; + } + + body.in-iframe .chat-container { + flex: 1; + min-height: 0; + } + + body.in-iframe .controls { + flex-shrink: 0; + margin-top: 12px; + padding-top: 8px; + padding-bottom: 8px; + } + + .chat-messages { + display: flex; + flex-direction: column; + gap: 16px; + padding-top: 20px; + } + + .play-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.25); + border-radius: var(--radius-lg); + cursor: pointer; + transition: opacity 0.3s ease; + z-index: 10; + } + + .play-overlay.hidden { + opacity: 0; + pointer-events: none; + } + + .play-overlay-icon { + width: 80px; + height: 80px; + background: rgba(255, 255, 255, 0.95); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + transition: transform 0.2s ease; + } + + .play-overlay:hover .play-overlay-icon { + transform: scale(1.05); + } + + .play-overlay-icon svg { + margin-left: 4px; + } + + @media (prefers-color-scheme: dark) { + .play-overlay { + background: rgba(0, 0, 0, 0.5); + } + .play-overlay-icon { + background: rgba(255, 255, 255, 0.9); + } + } + + .message { + max-width: 85%; + opacity: 0; + transform: translateY(10px); + } + + .message.visible { + opacity: 1; + transform: translateY(0); + transition: opacity 0.3s ease, transform 0.3s ease; + } + + .message.user { + align-self: flex-start; + } + + .message.ai { + align-self: flex-end; + margin-right: 32px; + } + + .message-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + color: var(--text-muted); + } + + .message.ai .message-label { + text-align: right; + } + + .message-content { + padding: 12px 16px; + border-radius: var(--radius); + border-left: 3px solid; + } + + .message.user .message-content { + background: var(--user-bg); + border-color: var(--user-border); + } + + .message.ai .message-content { + background: var(--ai-bg); + border-color: var(--ai-border); + } + + .message-content pre { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 10px; + overflow-x: auto; + font-size: 12px; + margin: 8px 0; + white-space: pre-wrap; + word-wrap: break-word; + } + + .message-content code { + font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; + } + + .annotation { + position: relative; + width: 100%; + margin: 20px 0; + padding: 0 0 0 16px; + font-size: 14px; + font-style: italic; + color: var(--annotation-text); + opacity: 0; + transform: translateY(10px); + } + + .annotation::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--annotation-border); + border-radius: 2px; + } + + .annotation.visible { + opacity: 1; + transform: translateY(0); + transition: opacity 0.4s ease, transform 0.4s ease; + } + + .annotation-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--annotation-border); + margin-bottom: 4px; + font-style: normal; + } + + .annotation-content { + line-height: 1.5; + } + + .transition { + position: relative; + width: 100%; + margin: 20px 0; + padding: 0 0 0 16px; + font-size: 14px; + font-weight: 500; + font-style: italic; + color: var(--transition-text, var(--text-muted)); + opacity: 0; + transform: translateY(10px); + } + + .transition::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--transition-border); + border-radius: 2px; + } + + .transition.visible { + opacity: 1; + transform: translateY(0); + transition: opacity 0.4s ease, transform 0.4s ease; + } + + .scenario-ending { + text-align: center; + padding: 24px 20px; + margin-top: 24px; + color: var(--text-muted); + font-size: 13px; + opacity: 0; + transition: opacity 0.4s ease; + } + + .scenario-ending.visible { + opacity: 1; + } + + .scenario-ending .next-label { + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 11px; + margin-bottom: 4px; + } + + .scenario-ending .next-title { + font-weight: 600; + font-size: 15px; + color: var(--text-primary); + } + + .controls { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: 16px; + padding-top: 16px; + flex-wrap: wrap; + } + + .control-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 14px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + border-radius: var(--radius); + cursor: pointer; + font-size: 13px; + transition: all 0.2s; + } + + .control-btn:hover:not(:disabled) { + background: var(--accent-light); + border-color: var(--accent); + } + + .control-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .control-btn.primary { + background: var(--accent); + color: white; + border-color: var(--accent); + } + + .control-btn.primary:hover:not(:disabled) { + opacity: 0.9; + } + + .control-btn.icon-only { + padding: 8px 10px; + } + + .speed-select { + padding: 8px 10px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + border-radius: var(--radius); + font-size: 13px; + cursor: pointer; + } + + .progress { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); + } + + .timer-bar { + position: absolute; + bottom: 1px; + left: 1px; + height: 3px; + width: 0%; + background: var(--accent); + opacity: 0; + border-radius: 0 0 2px 10px; + z-index: 5; + transform-origin: left; + transition: opacity 0.2s ease; + pointer-events: none; + } + + .timer-bar.active { + opacity: 0.6; + transition: width 0.1s linear, opacity 0.2s ease; + } + + .timer-circle { + position: absolute; + top: 12px; + right: 12px; + width: 18px; + height: 18px; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 5; + pointer-events: none; + } + + .timer-circle.active { + opacity: 0.6; + } + + .timer-circle circle { + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-dasharray: 44; + stroke-dashoffset: 0; + transform: rotate(-90deg); + transform-origin: center; + } + + .browser-error { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary); + } + + @media (prefers-reduced-motion: reduce) { + .message, + .annotation, + .transition { + opacity: 1 !important; + transform: none !important; + transition: none !important; + } + .play-overlay { + transition: none !important; + } + } + + @media (max-width: 600px) { + body { + padding: 12px; + } + + .demo-title { + font-size: 16px; + } + + .demo-description { + font-size: 13px; + } + + .tabs { + gap: 6px; + } + + .tab { + padding: 10px 14px; + font-size: 12px; + min-height: 44px; + } + + .message { + max-width: 95%; + } + + .chat-container { + padding: 12px; + min-height: 250px; + } + + body.in-iframe .chat-container { + max-height: 400px; + } + + .controls { + gap: 8px; + padding-top: 12px; + } + + .control-btn { + padding: 10px 14px; + font-size: 13px; + min-height: 44px; + } + + .control-btn.icon-only { + padding: 10px 12px; + min-width: 44px; + min-height: 44px; + } + + .speed-select { + padding: 10px 12px; + font-size: 13px; + min-height: 44px; + } + + .play-overlay-icon { + width: 70px; + height: 70px; + } + + .play-overlay-icon svg { + width: 28px; + height: 28px; + } + + .message-content pre { + overflow-x: auto; + max-width: 100%; + } + } + + /* Very small phones */ + @media (max-width: 400px) { + body { + padding: 8px; + } + + .tabs { + flex-direction: column; + gap: 4px; + } + + .tab { + width: 100%; + text-align: center; + } + + .controls { + flex-wrap: wrap; + justify-content: center; + } + + .control-btn { + flex: 0 0 auto; + } + + .control-btn.primary { + order: -1; + flex: 1 1 100%; + justify-content: center; + margin-bottom: 4px; + } + } + `; +} + +/** + * Generate the JavaScript for the demo player + */ +function generateJs(demo: Demo, timerStyle: TimerStyle): string { + const scenariosJs = generateScenariosJs(demo); + const scenarioOrder = JSON.stringify(demo.scenarios.map(s => s.id)); + const autoAdvance = demo.meta.autoAdvance !== false; + const hasMultipleScenarios = demo.scenarios.length > 1; + const annotationLabel = demo.meta.annotationLabel ?? 'Behind the Scenes'; + const useCircleTimer = timerStyle === 'circle'; + + return ` + (function() { + 'use strict'; + + // Feature detection + var hasRequiredFeatures = ( + typeof document.querySelector === 'function' && + typeof document.querySelectorAll === 'function' && + typeof window.addEventListener === 'function' && + typeof Array.prototype.forEach === 'function' + ); + + if (!hasRequiredFeatures) { + var errorDiv = document.querySelector('.browser-error'); + if (errorDiv) { + errorDiv.style.display = 'block'; + } + return; + } + + // Iframe detection - add class for CSS and hide header when embedded + var isInIframe = false; + try { + isInIframe = window.self !== window.top; + } catch (e) { + isInIframe = true; + } + + if (isInIframe) { + document.documentElement.classList.add('in-iframe'); + document.body.classList.add('in-iframe'); + + // Listen for theme changes from parent page + window.addEventListener('message', function(event) { + if (event.data && event.data.type === 'theme-change') { + var theme = event.data.theme; + if (theme === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + } else { + document.documentElement.removeAttribute('data-theme'); + } + } + }); + } + + var header = document.getElementById('demo-header'); + if (isInIframe && header) { + header.classList.add('hidden'); + } + + // Configurable annotation label + var annotationLabel = ${JSON.stringify(annotationLabel)}; + + // Scenarios data + var scenarios = ${scenariosJs}; + var scenarioOrder = ${scenarioOrder}; + var autoAdvance = ${autoAdvance}; + var hasMultipleScenarios = ${hasMultipleScenarios}; + var useCircleTimer = ${useCircleTimer}; + + // Speed configuration + var speedConfig = { + minDelay: ${demo.meta.speed?.minDelay ?? 3000}, + maxDelay: ${demo.meta.speed?.maxDelay ?? 8000}, + msPerWord: ${demo.meta.speed?.msPerWord ?? 200}, + annotationMultiplier: ${demo.meta.speed?.annotationMultiplier ?? 1.15}, + upNextDelay: ${demo.meta.speed?.upNextDelay ?? 2500} + }; + + // Animation state + var currentScenario = scenarioOrder[0]; + var currentStepIndex = 0; + var isPlaying = false; + var isPaused = false; + var animationTimeout = null; + var speed = 1; + var hasStarted = false; + + // DOM elements + var chatMessages = document.getElementById('chat-messages'); + var playPauseBtn = document.getElementById('play-pause-btn'); + var playIcon = document.getElementById('play-icon'); + var pauseIcon = document.getElementById('pause-icon'); + var playPauseText = document.getElementById('play-pause-text'); + var resetBtn = document.getElementById('reset-btn'); + var speedSelect = document.getElementById('speed-select'); + var progressEl = document.getElementById('progress'); + var playOverlay = document.getElementById('play-overlay'); + var timerElement = document.getElementById('timer-element'); + var chatContainer = document.getElementById('chat-container'); + var tabs = document.querySelectorAll('.tab'); + + // Timer animation + var timerAnimationFrame = null; + var timerStartTime = 0; + var timerDuration = 0; + + // Reduced motion preference + var prefersReducedMotion = false; + try { + prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } catch (e) {} + + function getSteps() { + return scenarios[currentScenario].steps; + } + + function getLabels() { + return scenarios[currentScenario].labels; + } + + // Content-aware delay calculation + function calculateDelay(step) { + var wordCount = 0; + + if (step.plainText) { + wordCount += step.plainText.split(/\\s+/).length; + } + if (step.codeBlock) { + wordCount += Math.floor(step.codeBlock.split(/\\s+/).length * 1.3); + } + if (step.footnote) { + wordCount += step.footnote.split(/\\s+/).length; + } + + var baseDelay = Math.max(speedConfig.minDelay, Math.min(speedConfig.maxDelay, wordCount * speedConfig.msPerWord)); + + if (step.type === 'annotation') { + baseDelay = Math.floor(baseDelay * speedConfig.annotationMultiplier); + } + + return baseDelay / speed; + } + + // Timer functions - supports bar (grows left to right) or circle (stroke countdown) + function startTimer(duration) { + timerDuration = duration; + timerStartTime = Date.now(); + timerElement.classList.add('active'); + + if (useCircleTimer) { + // Circle: animate stroke-dashoffset from 0 to 44 (full circle = 44) + var circle = timerElement.querySelector('circle'); + if (circle) circle.style.strokeDashoffset = '0'; + } else { + // Bar: start at 0% width + timerElement.style.width = '0%'; + } + + function animate() { + var elapsed = Date.now() - timerStartTime; + var progress = Math.min(elapsed / timerDuration, 1); + + if (useCircleTimer) { + // Circle: offset goes from 0 to 44 as time progresses + var circle = timerElement.querySelector('circle'); + if (circle) circle.style.strokeDashoffset = (progress * 44) + ''; + } else { + // Bar: width grows from 0% to 100% + timerElement.style.width = (progress * 100) + '%'; + } + + if (progress < 1 && isPlaying && !isPaused) { + timerAnimationFrame = requestAnimationFrame(animate); + } + } + + if (typeof requestAnimationFrame === 'function') { + timerAnimationFrame = requestAnimationFrame(animate); + } + } + + function stopTimer() { + if (timerAnimationFrame) { + cancelAnimationFrame(timerAnimationFrame); + timerAnimationFrame = null; + } + timerElement.classList.remove('active'); + if (useCircleTimer) { + var circle = timerElement.querySelector('circle'); + if (circle) circle.style.strokeDashoffset = '0'; + } else { + timerElement.style.width = '0%'; + } + } + + function clearChatMessages() { + while (chatMessages.firstChild) { + chatMessages.removeChild(chatMessages.firstChild); + } + } + + function smoothScrollToBottom() { + var container = chatMessages.parentElement; + if (typeof container.scrollTo === 'function') { + container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); + } else { + container.scrollTop = container.scrollHeight; + } + } + + function createStepElement(step) { + var div = document.createElement('div'); + var labels = getLabels(); + + if (step.type === 'annotation') { + div.className = 'annotation'; + + // Label + var label = document.createElement('div'); + label.className = 'annotation-label'; + label.textContent = annotationLabel; + div.appendChild(label); + + // Content + var content = document.createElement('div'); + content.className = 'annotation-content'; + content.textContent = step.plainText; + div.appendChild(content); + return div; + } + + if (step.type === 'transition') { + div.className = 'transition'; + div.textContent = step.plainText; + return div; + } + + // Message + div.className = 'message ' + step.type; + + var label = document.createElement('div'); + label.className = 'message-label'; + label.textContent = labels[step.type] || step.type; + div.appendChild(label); + + var contentWrapper = document.createElement('div'); + contentWrapper.className = 'message-content'; + + if (step.plainText) { + var textNode = document.createElement('p'); + var hasMoreContent = step.codeBlock || step.footnote; + textNode.style.margin = hasMoreContent ? '0 0 8px 0' : '0'; + textNode.textContent = step.plainText; + contentWrapper.appendChild(textNode); + } + + if (step.codeBlock) { + var pre = document.createElement('pre'); + var code = document.createElement('code'); + code.textContent = step.codeBlock; + pre.appendChild(code); + contentWrapper.appendChild(pre); + } + + if (step.footnote) { + var footnote = document.createElement('p'); + footnote.style.cssText = 'margin: 8px 0 0 0; font-style: italic; font-size: 12px; color: var(--text-secondary);'; + footnote.textContent = step.footnote; + contentWrapper.appendChild(footnote); + } + + div.appendChild(contentWrapper); + return div; + } + + function updateTabStates() { + tabs.forEach(function(t) { + var isActive = t.getAttribute('data-scenario') === currentScenario; + if (isActive) { + t.classList.add('active'); + t.setAttribute('aria-selected', 'true'); + t.setAttribute('tabindex', '0'); + } else { + t.classList.remove('active'); + t.setAttribute('aria-selected', 'false'); + t.setAttribute('tabindex', '-1'); + } + }); + } + + function updateButtonStates() { + var steps = getSteps(); + var isComplete = currentStepIndex >= steps.length; + var isActivelyPlaying = isPlaying && !isPaused; + + // Toggle play/pause button icon and text + if (isActivelyPlaying) { + playIcon.style.display = 'none'; + pauseIcon.style.display = 'block'; + playPauseText.textContent = 'Pause'; + playPauseBtn.setAttribute('aria-label', 'Pause animation'); + } else { + playIcon.style.display = 'block'; + pauseIcon.style.display = 'none'; + playPauseText.textContent = 'Play'; + playPauseBtn.setAttribute('aria-label', 'Play animation'); + } + + playPauseBtn.disabled = isComplete && !isActivelyPlaying; + } + + function updateProgress() { + var steps = getSteps(); + progressEl.textContent = currentStepIndex + ' / ' + steps.length; + } + + function showInitialPreview() { + var steps = getSteps(); + if (steps.length > 0) { + var element = createStepElement(steps[0]); + element.classList.add('visible'); + chatMessages.appendChild(element); + currentStepIndex = 0; + } + playOverlay.classList.remove('hidden'); + hasStarted = false; + updateProgress(); + updateButtonStates(); + } + + function switchToScenario(scenarioId, withFade) { + stopTimer(); + if (animationTimeout) { + clearTimeout(animationTimeout); + animationTimeout = null; + } + + function doSwitch() { + currentScenario = scenarioId; + currentStepIndex = 0; + isPlaying = false; + isPaused = false; + hasStarted = false; + + updateTabStates(); + clearChatMessages(); + showInitialPreview(); + + if (withFade) { + // Fade back in + chatContainer.classList.remove('fading'); + } + } + + if (withFade) { + // Fade out first + chatContainer.classList.add('fading'); + setTimeout(doSwitch, 300); + } else { + doSwitch(); + } + } + + function showTransitionMessage(nextScenarioTitle) { + var div = document.createElement('div'); + div.className = 'transition'; + div.textContent = 'Switching to: ' + nextScenarioTitle; + chatMessages.appendChild(div); + + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(function() { + div.classList.add('visible'); + smoothScrollToBottom(); + }); + } else { + div.classList.add('visible'); + smoothScrollToBottom(); + } + } + + function advanceToNextScenario() { + var currentIndex = scenarioOrder.indexOf(currentScenario); + var nextIndex = (currentIndex + 1) % scenarioOrder.length; + var nextScenarioId = scenarioOrder[nextIndex]; + var nextTitle = scenarios[nextScenarioId].title; + + // Show "Up Next" indicator at end of current scenario + var upNextDiv = document.createElement('div'); + upNextDiv.className = 'scenario-ending'; + var labelDiv = document.createElement('div'); + labelDiv.className = 'next-label'; + labelDiv.textContent = 'Up Next'; + var titleDiv = document.createElement('div'); + titleDiv.className = 'next-title'; + titleDiv.textContent = nextTitle; + upNextDiv.appendChild(labelDiv); + upNextDiv.appendChild(titleDiv); + chatMessages.appendChild(upNextDiv); + + // Animate it in + requestAnimationFrame(function() { + upNextDiv.classList.add('visible'); + smoothScrollToBottom(); + }); + + // Wait for user to see it, then fade and switch + var upNextDelay = speedConfig.upNextDelay / speed; + startTimer(upNextDelay); + animationTimeout = setTimeout(function() { + stopTimer(); + chatContainer.classList.add('fading'); + + setTimeout(function() { + currentScenario = nextScenarioId; + currentStepIndex = 0; + isPlaying = false; + isPaused = false; + hasStarted = false; + + updateTabStates(); + clearChatMessages(); + // Skip showInitialPreview() during auto-advance to avoid overlay flash + // Keep overlay hidden and go directly to playing + playOverlay.classList.add('hidden'); + chatContainer.classList.remove('fading'); + + // Auto-play immediately + play(); + }, 300); + }, upNextDelay); + } + + function showNextStep() { + stopTimer(); + var steps = getSteps(); + + if (currentStepIndex >= steps.length) { + // Scenario complete + if (autoAdvance && hasMultipleScenarios) { + // Advance to next scenario - it handles its own "Up Next" timing + advanceToNextScenario(); + } else { + isPlaying = false; + updateButtonStates(); + } + return; + } + + if (isPaused) return; + + var step = steps[currentStepIndex]; + var element = createStepElement(step); + chatMessages.appendChild(element); + + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(function() { + element.classList.add('visible'); + smoothScrollToBottom(); + }); + } else { + element.classList.add('visible'); + smoothScrollToBottom(); + } + + currentStepIndex++; + updateProgress(); + + if (currentStepIndex < steps.length) { + var delay = calculateDelay(step); + startTimer(delay); + animationTimeout = setTimeout(showNextStep, delay); + } else { + // Check if we should auto-advance + if (autoAdvance && hasMultipleScenarios) { + // Advance to next scenario - it handles its own "Up Next" timing + advanceToNextScenario(); + } else { + isPlaying = false; + updateButtonStates(); + } + } + } + + function play() { + if (!hasStarted) { + playOverlay.classList.add('hidden'); + hasStarted = true; + isPlaying = true; + isPaused = false; + updateButtonStates(); + + var steps = getSteps(); + var firstStep = steps[0]; + var delay = calculateDelay(firstStep); + currentStepIndex = 1; + + startTimer(delay); + animationTimeout = setTimeout(showNextStep, delay); + return; + } + + if (isPaused) { + isPaused = false; + updateButtonStates(); + showNextStep(); + return; + } + + if (isPlaying) return; + + var steps = getSteps(); + if (currentStepIndex >= steps.length) { + currentStepIndex = 0; + clearChatMessages(); + var element = createStepElement(steps[0]); + element.classList.add('visible'); + chatMessages.appendChild(element); + currentStepIndex = 1; + + isPlaying = true; + isPaused = false; + updateButtonStates(); + + var delay = calculateDelay(steps[0]); + startTimer(delay); + animationTimeout = setTimeout(showNextStep, delay); + return; + } + + isPlaying = true; + isPaused = false; + updateButtonStates(); + showNextStep(); + } + + function pause() { + isPaused = true; + updateButtonStates(); + stopTimer(); + if (animationTimeout) { + clearTimeout(animationTimeout); + animationTimeout = null; + } + } + + function reset() { + switchToScenario(currentScenario); + } + + function showAllInstantly() { + var steps = getSteps(); + clearChatMessages(); + playOverlay.classList.add('hidden'); + hasStarted = true; + + steps.forEach(function(step) { + var element = createStepElement(step); + element.classList.add('visible'); + chatMessages.appendChild(element); + }); + + currentStepIndex = steps.length; + isPlaying = false; + updateProgress(); + updateButtonStates(); + } + + // Event listeners + playOverlay.addEventListener('click', function() { + if (prefersReducedMotion) { + showAllInstantly(); + } else { + play(); + } + }); + + playOverlay.addEventListener('keydown', function(e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (prefersReducedMotion) { + showAllInstantly(); + } else { + play(); + } + } + }); + + // Tab switching logic (shared between click and keyboard) + function switchTab(tab) { + var scenarioId = tab.getAttribute('data-scenario'); + if (scenarioId === currentScenario) return; // Already on this tab + + // Remember if we were playing before switching + var wasPlaying = isPlaying && !isPaused; + + // Stop any current animation + if (animationTimeout) { + clearTimeout(animationTimeout); + animationTimeout = null; + } + stopTimer(); + + // Fade out, switch, fade in + chatContainer.classList.add('fading'); + setTimeout(function() { + currentScenario = scenarioId; + currentStepIndex = 0; + isPlaying = false; + isPaused = false; + hasStarted = false; + + updateTabStates(); + clearChatMessages(); + + // Keep overlay hidden to avoid flash + playOverlay.classList.add('hidden'); + chatContainer.classList.remove('fading'); + + // If was playing, continue playing; otherwise stay paused + if (wasPlaying && !prefersReducedMotion) { + play(); + } else if (prefersReducedMotion) { + showAllInstantly(); + } else { + // Stay paused - show initial preview but keep overlay hidden + // User can click play button or canvas to start + showInitialPreview(); + } + }, 300); + } + + // Tab click handlers + tabs.forEach(function(tab) { + tab.addEventListener('click', function() { + switchTab(tab); + }); + }); + + // Tab keyboard navigation (arrow keys per ARIA authoring practices) + tabs.forEach(function(tab, index) { + tab.addEventListener('keydown', function(e) { + var tabArray = Array.prototype.slice.call(tabs); + var currentIndex = tabArray.indexOf(tab); + var newIndex = currentIndex; + + if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + newIndex = currentIndex === 0 ? tabArray.length - 1 : currentIndex - 1; + e.preventDefault(); + } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + newIndex = currentIndex === tabArray.length - 1 ? 0 : currentIndex + 1; + e.preventDefault(); + } else if (e.key === 'Home') { + newIndex = 0; + e.preventDefault(); + } else if (e.key === 'End') { + newIndex = tabArray.length - 1; + e.preventDefault(); + } + + if (newIndex !== currentIndex) { + tabArray[newIndex].focus(); + switchTab(tabArray[newIndex]); + } + }); + }); + + function togglePlayPause() { + if (isPlaying && !isPaused) { + pause(); + } else { + play(); + } + } + + playPauseBtn.addEventListener('click', togglePlayPause); + resetBtn.addEventListener('click', reset); + + speedSelect.addEventListener('change', function(e) { + speed = parseFloat(e.target.value); + }); + + // Handle page visibility changes (pause timer animation when tab is hidden) + if (typeof document.hidden !== 'undefined') { + document.addEventListener('visibilitychange', function() { + if (document.hidden && timerAnimationFrame) { + cancelAnimationFrame(timerAnimationFrame); + timerAnimationFrame = null; + } + }); + } + + // Initialize + updateTabStates(); + showInitialPreview(); + + if (prefersReducedMotion) { + showAllInstantly(); + } + })(); + `; +} + +/** + * Generate tabs HTML + */ +function generateTabsHtml(demo: Demo): string { + if (demo.scenarios.length <= 1) { + return ''; + } + + const tabButtons = demo.scenarios.map((scenario, i) => { + const activeClass = i === 0 ? ' active' : ''; + const ariaSelected = i === 0 ? 'true' : 'false'; + const tabIndex = i === 0 ? '0' : '-1'; // Only first tab in focus order initially + return ``; + }).join('\n '); + + return ` + + `; +} + +/** + * Generate the complete HTML document + */ +export function generateHtml(demo: Demo, options: BuildOptions = { outputPath: '' }): string { + const theme = options.theme ?? demo.meta.theme ?? 'chat'; + const includeHeader = options.includeHeader !== false; + const hasMultipleScenarios = demo.scenarios.length > 1; + const timerStyle = demo.meta.timerStyle ?? 'circle'; + const cornerStyle = demo.meta.cornerStyle ?? 'rounded'; + + const css = generateCss(theme, hasMultipleScenarios, demo.meta.colors, cornerStyle); + const js = generateJs(demo, timerStyle); + + const titleHtml = demo.meta.articleUrl + ? `${escapeHtml(demo.meta.title)}` + : escapeHtml(demo.meta.title); + + const descriptionHtml = demo.meta.description + ? `

${parseMarkdownLinks(demo.meta.description)}

` + : ''; + + const headerHtml = includeHeader + ? ` +
+

${titleHtml}

+ ${descriptionHtml} +
+ ` + : ''; + + const tabsHtml = generateTabsHtml(demo); + + const timerHtml = timerStyle === 'circle' + ? `` + : ``; + + return ` + + + + + ${escapeHtml(demo.meta.title)} + + + +
+ ${headerHtml} + ${tabsHtml} +
+
+
+
+ ${timerHtml} +
+
+ + + +
+
+
+ +
+ + + + +
+ + +
+ + + +`; +} + +/** + * Build a demo file to HTML output + */ +export async function buildDemo( + demo: Demo, + outputPath: string, + options: Partial = {} +): Promise { + const html = generateHtml(demo, { ...options, outputPath }); + await Bun.write(outputPath, html); +} + diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..51d0880 --- /dev/null +++ b/src/parser.ts @@ -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; + + 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; + + 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; + 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; + 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(); + + 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; + 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(); + + 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; + + 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; + 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 { + 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); +} + diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..35d47a1 --- /dev/null +++ b/src/types.ts @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -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 + } +}