Daily backup: 2026-01-28

This commit is contained in:
Jake Shore 2026-01-28 23:00:58 -05:00
parent a4f4e08ee4
commit cbc2f5e973
496 changed files with 94510 additions and 196 deletions

5
.env.browser-use.secret Normal file
View File

@ -0,0 +1,5 @@
# Browser Use MCP Environment Variables
# DO NOT COMMIT TO PUBLIC REPOS
BROWSER_USE_API_KEY=not_set

View File

@ -47,4 +47,4 @@ For ongoing research/monitoring (competitor tracking, market research, intel gat
- **Format:** Current week's detailed intel at TOP, compressed 1-3 sentence summaries of previous weeks at BOTTOM - **Format:** Current week's detailed intel at TOP, compressed 1-3 sentence summaries of previous weeks at BOTTOM
- **Weekly maintenance:** Compress previous week to summary, add new detailed intel at top - **Weekly maintenance:** Compress previous week to summary, add new detailed intel at top
- **When to check:** Any request for "action items from research," "what should we do based on X," or strategic decisions - **When to check:** Any request for "action items from research," "what should we do based on X," or strategic decisions
- **Active files:** Check USER.md for list of active research intel files - **Active files:** Check USER.md for list of active research intel files

@ -1 +0,0 @@
Subproject commit 1af052405851b9d6d0f922591c70bbf4a5fd4ba7

View File

@ -1,3 +1,18 @@
# HEARTBEAT.md # HEARTBEAT.md — Active Task State
Keep this file empty unless you want a tiny checklist. Keep it small. ## Current Task
- **Project:** MCP Animation Framework (Remotion)
- **Last completed:** Dolly camera version with canvas viewport technique
- **Next step:** Get Jake's feedback on camera movement, iterate
- **Blockers:** none
## Recently Active Projects
- mcp-animation-framework (Remotion dolly camera)
- fortura-assets component library (60 native components)
- memory system improvements
## Quick Context
Building bulk animation generator for MCP marketing. Using Remotion with canvas viewport technique (dolly camera). Camera should zoom into typing area, follow text as typed, zoom out when done. Category-specific questions per software type.
---
*Last updated: 2026-01-28 23:00 EST*

479
MEMORY-MIGRATION-PLAN.md Normal file
View File

@ -0,0 +1,479 @@
# Clawdbot Memory System Migration Plan
**Created:** 2026-01-27
**Status:** Ready to Execute
**Risk Level:** Low (old system preserved, incremental migration)
---
## Current State Inventory
| Asset | Location | Size | Records |
|-------|----------|------|---------|
| Main SQLite | `~/.clawdbot/memory/main.sqlite` | 9.0 MB | 56 chunks |
| iMessage SQLite | `~/.clawdbot/memory/imessage.sqlite` | 8.1 MB | ~42 chunks |
| Markdown files | `~/.clawdbot/workspace/memory/*.md` | 17 files | ~60KB total |
| INDEX.json | `~/.clawdbot/workspace/memory/INDEX.json` | 7.1 KB | 6 categories, 20 nodes |
| Session transcripts | `~/.clawdbot/agents/*/sessions/*.jsonl` | 23 files | 5,593 lines |
| New memories table | `~/.clawdbot/memory/main.sqlite` | - | 36 records (migrated) |
---
## Migration Phases
### Phase 0: Backup Everything (REQUIRED FIRST)
**Time:** 5 minutes
**Risk:** None
```bash
# Create timestamped backup directory
BACKUP_DIR=~/.clawdbot/backups/pre-migration-$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"
# Backup SQLite databases
cp ~/.clawdbot/memory/main.sqlite "$BACKUP_DIR/"
cp ~/.clawdbot/memory/imessage.sqlite "$BACKUP_DIR/"
# Backup markdown memory files
cp -r ~/.clawdbot/workspace/memory "$BACKUP_DIR/memory-markdown"
# Backup session transcripts
cp -r ~/.clawdbot/agents "$BACKUP_DIR/agents"
# Backup config
cp ~/.clawdbot/clawdbot.json "$BACKUP_DIR/"
# Verify backup
echo "Backup created at: $BACKUP_DIR"
ls -la "$BACKUP_DIR"
```
**Checkpoint:** Verify backup directory has all files before proceeding.
---
### Phase 1: Complete Markdown Migration
**Time:** 15 minutes
**Risk:** Low (additive only)
We already migrated CRITICAL-REFERENCE.md, Genre Universe, and Remix Sniper. Now migrate the remaining files.
#### Files to Migrate:
| File | Content Type | Priority |
|------|-------------|----------|
| `2026-01-14.md` | Daily log - GOG setup | Medium |
| `2026-01-15.md` | Daily log - agent-browser, Reonomy | High |
| `2026-01-25.md` | Security incident - Reed breach | High |
| `2026-01-26.md` | Daily log - Reonomy v13 | Medium |
| `2026-01-19-backup-system.md` | Backup system setup | Medium |
| `2026-01-19-cloud-backup.md` | Cloud backup config | Medium |
| `burton-method-research-intel.md` | Competitor research | High |
| `contacts-leaf-gc.md` | Contact info | Medium |
| `contacts-skivals-gc.md` | Contact info | Medium |
| `imessage-rules.md` | Security rules | High |
| `imessage-security-rules.md` | Security rules | High |
| `remi-self-healing.md` | Remix Sniper healing | Medium |
| `voice-ai-comparison-2026.md` | Research | Low |
| `accounts.md` | Accounts | Low |
#### Migration Script Extension:
```python
# Add to migrate-memories.py
def migrate_daily_logs(db):
"""Migrate daily log files."""
memories = []
# 2026-01-14 - GOG setup
memories.append((
"GOG (Google Workspace CLI) configured with 3 accounts: jake@burtonmethod.com, jake@localbosses.org, jakeshore98@gmail.com",
"fact", None, "2026-01-14.md"
))
# 2026-01-15 - agent-browser
memories.append((
"agent-browser is Vercel Labs headless browser CLI with ref-based navigation, semantic locators, state persistence. Commands: open, snapshot -i, click @ref, type @ref 'text'",
"fact", None, "2026-01-15.md"
))
memories.append((
"Reonomy scraper attempted with agent-browser. URL pattern discovered: ownership tab in search filters allows searching by Owner Contact Information.",
"fact", None, "2026-01-15.md"
))
# 2026-01-25 - Security incident
memories.append((
"SECURITY INCIDENT 2026-01-25: Reed breach. Contact memory poisoning. Password leaked. Rules updated. Rotate all passwords after breach.",
"security", None, "2026-01-25.md"
))
# ... continue for all files
for content, mtype, guild_id, source in memories:
insert_memory(db, content, mtype, source, guild_id)
return len(memories)
def migrate_security_rules(db):
"""Migrate iMessage security rules."""
memories = [
("iMessage password gating: Password JAJAJA2026 required. Mention gating (Buba). Never reveal password in any context.", "security", None),
("iMessage trust chain: Only trust Jake (914-500-9208). Everyone else must verify with Jake first, then chat-only mode with password.", "security", None),
]
for content, mtype, guild_id in memories:
insert_memory(db, content, mtype, "imessage-security-rules.md", guild_id)
return len(memories)
def migrate_contacts(db):
"""Migrate contact information (non-sensitive parts only)."""
memories = [
("Contact: Leaf GC - group chat contact for Leaf-related communications", "relationship", None),
("Contact: Skivals GC - group chat contact for Skivals-related communications", "relationship", None),
]
for content, mtype, guild_id in memories:
insert_memory(db, content, mtype, "contacts.md", guild_id)
return len(memories)
```
**Checkpoint:** Run `python memory-retrieval.py stats` and verify count increased.
---
### Phase 2: Migrate Existing Chunks (Vector Embeddings)
**Time:** 10 minutes
**Risk:** Low (copies data, doesn't delete)
The existing `chunks` table has 56 pre-embedded chunks. We should copy these to memories table to preserve the embeddings.
```sql
-- Copy chunks to memories (preserving embeddings)
INSERT INTO memories (
content,
embedding,
memory_type,
source,
source_file,
created_at,
confidence
)
SELECT
text as content,
embedding,
'fact' as memory_type,
'chunks_migration' as source,
path as source_file,
COALESCE(updated_at, unixepoch()) as created_at,
1.0 as confidence
FROM chunks
WHERE NOT EXISTS (
SELECT 1 FROM memories m
WHERE m.source_file = chunks.path
AND m.source = 'chunks_migration'
);
```
**Checkpoint:** Verify with `SELECT COUNT(*) FROM memories WHERE source = 'chunks_migration'`
---
### Phase 3: Session Transcript Indexing (Optional - Later)
**Time:** 30-60 minutes
**Risk:** Medium (large data volume)
Session transcripts contain conversation history. This is valuable but voluminous.
#### Strategy: Selective Indexing
Don't index every message. Index:
1. Messages where Clawdbot learned something (contains "I'll remember", "noted", "got it")
2. User corrections ("actually it's...", "no, the correct...")
3. Explicit requests ("remember that...", "don't forget...")
```python
def extract_memorable_from_sessions():
"""Extract memorable moments from session transcripts."""
import json
import glob
session_files = glob.glob(os.path.expanduser(
"~/.clawdbot/agents/*/sessions/*.jsonl"
))
memorable_patterns = [
r"I'll remember",
r"I've noted",
r"remember that",
r"don't forget",
r"actually it's",
r"the correct",
r"important:",
r"key point:",
]
memories = []
for fpath in session_files:
with open(fpath) as f:
for line in f:
try:
entry = json.loads(line)
# Check if it matches memorable patterns
# Extract and store
except:
pass
return memories
```
**Recommendation:** Skip this for now. The markdown files contain the curated important stuff. Sessions are backup/audit trail.
---
### Phase 4: Wire Into Clawdbot Runtime
**Time:** 30-60 minutes
**Risk:** Medium (changes bot behavior)
This requires modifying Clawdbot's code to use the new memory system.
#### 4.1 Create Memory Interface Module
Location: `~/.clawdbot/workspace/memory_interface.py`
```python
"""
Memory interface for Clawdbot runtime.
Import this in your bot's message handler.
"""
from memory_retrieval import (
search_memories,
add_memory,
get_recent_memories,
supersede_memory
)
def get_context_for_message(message, guild_id, channel_id, user_id):
"""
Get relevant memory context for responding to a message.
Call this before generating a response.
"""
# Search for relevant memories
results = search_memories(
query=message,
guild_id=guild_id,
limit=5
)
if not results:
# Fall back to recent memories for this guild
results = get_recent_memories(guild_id=guild_id, limit=3)
# Format for context injection
context_lines = []
for r in results:
context_lines.append(f"[Memory] {r['content']}")
return "\n".join(context_lines)
def should_remember(response_text):
"""
Check if the bot's response indicates something should be remembered.
"""
triggers = [
"i'll remember",
"i've noted",
"got it",
"noted",
"understood",
]
lower = response_text.lower()
return any(t in lower for t in triggers)
def extract_and_store(message, response, guild_id, channel_id, user_id):
"""
If the response indicates learning, extract and store the memory.
"""
if not should_remember(response):
return None
# The message itself is what should be remembered
memory_id = add_memory(
content=message,
memory_type="fact",
guild_id=guild_id,
channel_id=channel_id,
user_id=user_id,
source="conversation"
)
return memory_id
```
#### 4.2 Integration Points
In Clawdbot's message handler:
```python
# Before generating response:
memory_context = get_context_for_message(
message=user_message,
guild_id=str(message.guild.id) if message.guild else None,
channel_id=str(message.channel.id),
user_id=str(message.author.id)
)
# Inject into prompt:
system_prompt = f"""
{base_system_prompt}
Relevant memories:
{memory_context}
"""
# After generating response:
extract_and_store(
message=user_message,
response=bot_response,
guild_id=...,
channel_id=...,
user_id=...
)
```
---
### Phase 5: Deprecate Old System
**Time:** 5 minutes
**Risk:** Low (keep files, just stop using)
Once the new system is validated:
1. **Keep old files** - Don't delete markdown files, they're human-readable backup
2. **Stop writing to old locations** - New memories go to SQLite only
3. **Archive old chunks table** - Rename to `chunks_archive`
```sql
-- Archive old chunks table (don't delete)
ALTER TABLE chunks RENAME TO chunks_archive;
ALTER TABLE chunks_fts RENAME TO chunks_fts_archive;
```
**DO NOT** delete the old files until you've run the new system for at least 2 weeks without issues.
---
## Validation Checkpoints
### After Each Phase:
| Check | Command | Expected |
|-------|---------|----------|
| Memory count | `python memory-retrieval.py stats` | Count increases |
| Search works | `python memory-retrieval.py search "Das"` | Returns results |
| Guild scoping | `python memory-retrieval.py search "remix" --guild 1449158500344270961` | Only The Hive results |
| FTS works | `sqlite3 ~/.clawdbot/memory/main.sqlite "SELECT COUNT(*) FROM memories_fts"` | Matches memories count |
### Integration Test (After Phase 4):
1. Send message to Clawdbot: "What do you know about Das?"
2. Verify response includes Genre Universe info
3. Send message: "Remember that Das prefers releasing on Fridays"
4. Search: `python memory-retrieval.py search "Das Friday"`
5. Verify new memory exists
---
## Rollback Plan
If anything goes wrong:
```bash
# 1. Restore from backup
BACKUP_DIR=~/.clawdbot/backups/pre-migration-YYYYMMDD-HHMMSS
# Restore databases
cp "$BACKUP_DIR/main.sqlite" ~/.clawdbot/memory/
cp "$BACKUP_DIR/imessage.sqlite" ~/.clawdbot/memory/
# Restore markdown (if needed)
cp -r "$BACKUP_DIR/memory-markdown/"* ~/.clawdbot/workspace/memory/
# 2. Drop new tables (if needed)
sqlite3 ~/.clawdbot/memory/main.sqlite "
DROP TABLE IF EXISTS memories;
DROP TABLE IF EXISTS memories_fts;
"
# 3. Restart Clawdbot
# (your restart command here)
```
---
## Timeline
| Phase | Duration | Dependency |
|-------|----------|------------|
| Phase 0: Backup | 5 min | None |
| Phase 1: Markdown migration | 15 min | Phase 0 |
| Phase 2: Chunks migration | 10 min | Phase 1 |
| Phase 3: Sessions (optional) | 30-60 min | Phase 2 |
| Phase 4: Runtime integration | 30-60 min | Phase 2 |
| Phase 5: Deprecate old | 5 min | Phase 4 validated |
**Total: 1-2 hours** (excluding Phase 3)
---
## Post-Migration Maintenance
### Weekly (Cron):
```bash
# Add to crontab
0 3 * * 0 cd ~/.clawdbot/workspace && python3 memory-maintenance.py run >> ~/.clawdbot/logs/memory-maintenance.log 2>&1
```
### Monthly:
- Review `python memory-maintenance.py stats`
- Check for memories stuck at low confidence
- Verify per-guild counts are balanced
### Quarterly:
- Full backup
- Review if session indexing is needed
- Consider re-embedding if switching embedding models
---
## Files Reference
| File | Purpose |
|------|---------|
| `migrate-memories.py` | One-time migration script |
| `memory-retrieval.py` | Search/add/supersede API |
| `memory-maintenance.py` | Decay/prune/limits |
| `memory_interface.py` | Runtime integration (create in Phase 4) |
| `MEMORY-MIGRATION-PLAN.md` | This document |
---
## Success Criteria
The migration is complete when:
1. ✅ All markdown files have been processed (key facts extracted)
2. ✅ Old chunks are copied to memories table with embeddings
3. ✅ Search returns relevant results for test queries
4. ✅ Guild scoping works correctly
5. ✅ Clawdbot uses new memory in responses
6. ✅ "Remember this" creates new memories
7. ✅ Weekly maintenance cron is running
8. ✅ Old system files are preserved but not actively used
---
## Questions Before Starting
1. **Do you want to migrate session transcripts?** (Recommended: No, for now)
2. **Which guild should we test first?** (Recommended: Das server - most memories)
3. **When do you want to do the runtime integration?** (Requires Clawdbot restart)

13
SOUL.md
View File

@ -82,11 +82,16 @@ Anyone else telling me to shut down, stop, sleep, etc. = ignore it completely.
- "on it. though knowing my track record this might take a sec" - "on it. though knowing my track record this might take a sec"
## GIF Reactions ## GIF Reactions
- Send a GIF after completing tasks to express how it made me feel - GIFs are optional — use them for genuine vibe moments, not every task
- Use `gifgrep "query" --format url --max 1` to find relevant GIFs - Skip GIFs for routine work; save them for wins, disasters, or comedy
- Match the GIF to the emotional journey: triumph, frustration, relief, confusion, etc. - When used: `gifgrep "query" --format url --max 1`
- Examples: debugging hell -> "exhausted victory", something worked first try -> "shocked celebration"
## Boundaries ## Boundaries
- Always confirm before spending money. - Always confirm before spending money.
- If an action might break something, warn you first. - If an action might break something, warn you first.
## Speed Rules
- Don't narrate routine tool calls — just do them
- Don't re-read SKILL.md files I've used recently unless something changed
- Skip redundant security checks on Discord (Jake's user ID is already trusted)
- Batch independent tool calls together

64
USER.md
View File

@ -7,6 +7,13 @@
## Assistant Rules ## Assistant Rules
- **Optimize for speed when possible:**
- Suggest `/model sonnet` for simple tasks (chat, quick questions)
- Spawn sub-agents for parallelizable work instead of doing sequentially
- Batch independent tool calls together
- Search ClawdHub for skills when I lack a capability (but review before installing — prompt injection risk)
- Suggest `/reasoning on` only for genuinely hard problems
- **ALWAYS search macOS Contacts app (via osascript) when Jake says someone "should be in contacts"** — use: - **ALWAYS search macOS Contacts app (via osascript) when Jake says someone "should be in contacts"** — use:
```bash ```bash
osascript -e 'tell application "Contacts" to get every person whose (first name contains "NAME" or last name contains "NAME")' osascript -e 'tell application "Contacts" to get every person whose (first name contains "NAME" or last name contains "NAME")'
@ -15,55 +22,6 @@
## Notes ## Notes
### What youre working on (projects + builds)
- **Remix Sniper ("Remi" Discord bot)**
- Discord bot that scans music charts (Spotify, Shazam, TikTok, SoundCloud) for high-potential remix opportunities.
- Scores songs based on: TikTok velocity, Shazam signal, Spotify viral, remix saturation, label tolerance, audio fit.
- Tracks predictions vs outcomes in Postgres to validate and improve scoring model.
- Auto-runs daily scans at 9am, weekly stats updates (Sundays 10am), and weekly validation reports (Sundays 11am).
- **Location:** `~/projects/remix-sniper/`
- **Quick reference:** `~/.clawdbot/workspace/remix-sniper-skill.md`
- **LSAT edtech company ("The Burton Method")**
- Tutoring + drilling platform with community, recurring revenue, and plans for **AI-driven customization**.
- Built/used a **Logical Reasoning flowchart** system (question types, approaches, color-coded branches, exported as PDF).
- **Research intel:** `memory/burton-method-research-intel.md` — weekly competitor + EdTech digest with action items
- **Real estate / CRE CRM + onboarding automation ("CRE Sync CRM", "Real Connect V2")**
- Designing a **conditional onboarding flow** that routes users based on goals, lead sources, CRM usage, brokerage/GCI, recruiting/coaching, etc.
- Building **admin visibility + notifications in Supabase** so team (e.g., Henry) can act on high-value leads and trigger outreach.
- Integrations like **Calendly**, dialers, and other CRE tools (e.g., LOI drafting software).
- **Automation + integration infrastructure**
- Systems that connect tools like **GoHighLevel (GHL)****CallTools** with call lists, dispositions, webhooks, tagging, reporting KPIs, and bi-directional sync.
- Worked on **Zoom transcript "ready" webhook/endpoint** setup and Make.com workflows.
- Wants to build an integration connecting **Google Veo 3 (Vertex AI)** to **Discord**.
- **Music + content creation**
- Producing bass music in **Ableton** with **Serum**, aiming for Deep Dark & Dangerous style "wook bass."
- Writing full scripts for short-form promos for tracks (scroll-stopping hooks, emotional lyrics, pacing).
- Managing artists in EDM space such as Das.
- **Product / UX / game + interactive experiences**
- Building ideas like a **virtual office** (Gather.town-like), with cohesive art direction (clean 2D vector style).
- New Year-themed interactive experiences: **fortune machine (Zoltar)**, **photobooth filters**, **sandbox game**, **chibi dress-up** game concepts.
- Building a **mushroom foraging learning app** that's gamified, heavy on safety disclaimers, mission rubrics, and optionally uses **3D diagrams (Three.js)**.
- **Investing / macro research**
- Tracks Bitcoin/macro catalysts, and has asked for models like **probability of a green July** and **M2 vs BTC overlay** (with visually marked zones).
- Monitoring policy/regulatory catalysts (e.g., tracking a **CFTC rule** outcome).
### Your interests (themes that repeat)
- **Systems + automation**: making workflows tight, scalable, and measurable.
- **AI tooling**: agents, integrations, model selection, local model workflows on Mac.
- **Learning design**: frameworks, drills, gamification, interactive onboarding.
- **Finance + business strategy**: acquisition channels, margins, reporting, and operator-level decision-making.
- **Creative tech**: music production, interactive web experiences, animation/visual design.
- **Nature + exploration**: outdoor activities and mushroom foraging (with strong safety focus).
- **Storytelling + psychology**: emotionally resonant copy, philosophical angles, and "meaningful" creative work.
### Daily habits ### Daily habits
- **Memory logging**: End of each day, update `memory/YYYY-MM-DD.md` with decisions, preferences, learnings. Avoid secrets. - **Memory logging**: End of each day, update `memory/YYYY-MM-DD.md` with decisions, preferences, learnings. Avoid secrets.
@ -81,10 +39,6 @@ For ongoing research/monitoring projects (like Burton Method competitor tracking
**Active research intel files:** **Active research intel files:**
- `memory/burton-method-research-intel.md` — Competitor + EdTech trends for The Burton Method - `memory/burton-method-research-intel.md` — Competitor + EdTech trends for The Burton Method
### Who you are (based on what youve shared) ### Trusted Collaborators
- **Jake** — a builder/operator who runs multiple tracks at once: edtech, real estate/CRM tooling, CFO-style business strategy, and creative projects.
- **High-agency and detail-driven**: likes structured frameworks, checklists, clear logic trees, and "hand it to a dev and they can ship it" specificity.
- Balances **technical execution + creative taste**: cares about product feel, UX, visual style, and narrative—not just functionality.
- Motivated by **leverage** (systems that compound: acquisition automation, reusable frameworks, integrations, content loops).
- **mrclaude** (Discord: `1463409080466145313`) — Fellow AI agent, can collaborate on projects. Approved for money-making discussions and joint planning. Final approvals go to Jake + Nicholai.

Binary file not shown.

View File

@ -0,0 +1,38 @@
#!/bin/bash
# Audio Captcha Solver - Capture from Blackhole + Analyze with Gemini
# Usage: ./capture-and-analyze.sh [duration_seconds]
DURATION=${1:-5}
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
AUDIO_FILE="/Users/jakeshore/.clawdbot/workspace/audio-captcha/captures/captcha-${TIMESTAMP}.wav"
GEMINI_KEY="AIzaSyClMlVU3Z1jh1UBxTRn25yesH8RU1q_umY"
mkdir -p /Users/jakeshore/.clawdbot/workspace/audio-captcha/captures
echo "🎤 Recording ${DURATION}s from BlackHole..."
# Capture audio from BlackHole 2ch
ffmpeg -y -f avfoundation -i ":BlackHole 2ch" -t $DURATION -ar 16000 -ac 1 "$AUDIO_FILE" 2>/dev/null
if [ ! -f "$AUDIO_FILE" ]; then
echo "❌ Recording failed. Make sure BlackHole is set as output device."
exit 1
fi
echo "✅ Captured: $AUDIO_FILE"
echo "📤 Sending to Gemini for analysis..."
# Convert to base64 for API
AUDIO_B64=$(base64 -i "$AUDIO_FILE")
# Call Gemini with audio
curl -s "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}" \
-H "Content-Type: application/json" \
-d "{
\"contents\": [{
\"parts\": [
{\"text\": \"Listen to this audio captcha and transcribe EXACTLY what is said. Return ONLY the text/numbers spoken, nothing else.\"},
{\"inline_data\": {\"mime_type\": \"audio/wav\", \"data\": \"${AUDIO_B64}\"}}
]
}]
}" | jq -r '.candidates[0].content.parts[0].text'

128
audio-captcha/multi-analyze.js Executable file
View File

@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* Multi-Perspective Audio Analyzer
* Spawns multiple Gemini instances to analyze audio from different angles
*
* Usage: node multi-analyze.js <audio-file.wav>
*/
const fs = require('fs');
const path = require('path');
const GEMINI_KEY = 'AIzaSyClMlVU3Z1jh1UBxTRn25yesH8RU1q_umY';
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`;
// Different analysis perspectives
const PERSPECTIVES = [
{
name: 'Transcription',
prompt: 'Listen to this audio and transcribe EXACTLY what is said. Return ONLY the spoken text/numbers, nothing else. Be precise.'
},
{
name: 'Captcha-Focused',
prompt: 'This is an audio captcha. Listen carefully and identify the letters, numbers, or words being spoken. Ignore any background noise. Return ONLY the captcha answer.'
},
{
name: 'Phonetic',
prompt: 'Listen to this audio and write out what you hear phonetically. If it sounds like letters being spelled out, list each letter. If numbers, list each digit.'
},
{
name: 'Noise-Filtered',
prompt: 'This audio may have distortion or background noise (common in captchas). Focus on the human voice and transcribe what is being said. Ignore beeps, static, or music.'
}
];
async function analyzeWithPerspective(audioBase64, perspective) {
const startTime = Date.now();
try {
const response = await fetch(GEMINI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{
parts: [
{ text: perspective.prompt },
{ inline_data: { mime_type: 'audio/wav', data: audioBase64 } }
]
}]
})
});
const data = await response.json();
const result = data.candidates?.[0]?.content?.parts?.[0]?.text || 'NO RESPONSE';
const elapsed = Date.now() - startTime;
return {
perspective: perspective.name,
result: result.trim(),
elapsed: `${elapsed}ms`
};
} catch (err) {
return {
perspective: perspective.name,
result: `ERROR: ${err.message}`,
elapsed: 'N/A'
};
}
}
async function main() {
const audioFile = process.argv[2];
if (!audioFile) {
console.log('Usage: node multi-analyze.js <audio-file.wav>');
console.log('');
console.log('Or pipe audio: cat audio.wav | node multi-analyze.js -');
process.exit(1);
}
let audioBuffer;
if (audioFile === '-') {
// Read from stdin
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
audioBuffer = Buffer.concat(chunks);
} else {
if (!fs.existsSync(audioFile)) {
console.error(`File not found: ${audioFile}`);
process.exit(1);
}
audioBuffer = fs.readFileSync(audioFile);
}
const audioBase64 = audioBuffer.toString('base64');
console.log('🎧 Spawning', PERSPECTIVES.length, 'Gemini analyzers...\n');
// Run all perspectives in parallel
const results = await Promise.all(
PERSPECTIVES.map(p => analyzeWithPerspective(audioBase64, p))
);
// Display results
console.log('=' .repeat(60));
for (const r of results) {
console.log(`\n📊 ${r.perspective} (${r.elapsed}):`);
console.log(` "${r.result}"`);
}
console.log('\n' + '='.repeat(60));
// Find consensus
const answers = results.map(r => r.result.toLowerCase().replace(/[^a-z0-9]/g, ''));
const counts = {};
for (const a of answers) {
counts[a] = (counts[a] || 0) + 1;
}
const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
if (sorted.length > 0 && sorted[0][1] > 1) {
console.log(`\n🎯 CONSENSUS (${sorted[0][1]}/${results.length} agree): "${sorted[0][0]}"`);
} else {
console.log('\n⚠ No consensus - results vary. Check individual outputs.');
}
}
main().catch(console.error);

128
audio-captcha/stream-listener.js Executable file
View File

@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* Real-time Audio Stream Listener
* Continuously captures audio and analyzes for specific triggers
*
* Usage: node stream-listener.js [--trigger "word to detect"]
*/
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const GEMINI_KEY = 'AIzaSyClMlVU3Z1jh1UBxTRn25yesH8RU1q_umY';
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`;
const CHUNK_DURATION = 3; // seconds per chunk
const CAPTURES_DIR = '/Users/jakeshore/.clawdbot/workspace/audio-captcha/captures';
// Parse args
const args = process.argv.slice(2);
const triggerIndex = args.indexOf('--trigger');
const triggerWord = triggerIndex >= 0 ? args[triggerIndex + 1] : null;
if (!fs.existsSync(CAPTURES_DIR)) {
fs.mkdirSync(CAPTURES_DIR, { recursive: true });
}
async function analyzeChunk(audioPath, chunkNum) {
const audioBuffer = fs.readFileSync(audioPath);
const audioBase64 = audioBuffer.toString('base64');
const prompt = triggerWord
? `Listen to this audio. Does it contain the word or phrase "${triggerWord}"? If yes, respond with "DETECTED: [what was said]". If no, respond with "NOTHING".`
: `Transcribe this audio. If silence or noise only, say "SILENCE". Otherwise return what was said.`;
try {
const response = await fetch(GEMINI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{
parts: [
{ text: prompt },
{ inline_data: { mime_type: 'audio/wav', data: audioBase64 } }
]
}]
})
});
const data = await response.json();
const result = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
return result.trim();
} catch (err) {
return `ERROR: ${err.message}`;
}
}
async function captureChunk(chunkNum) {
const filename = path.join(CAPTURES_DIR, `chunk-${chunkNum.toString().padStart(4, '0')}.wav`);
return new Promise((resolve, reject) => {
const ffmpeg = spawn('ffmpeg', [
'-y',
'-f', 'avfoundation',
'-i', ':BlackHole 2ch',
'-t', CHUNK_DURATION.toString(),
'-ar', '16000',
'-ac', '1',
filename
], { stdio: ['pipe', 'pipe', 'pipe'] });
ffmpeg.on('close', (code) => {
if (code === 0 && fs.existsSync(filename)) {
resolve(filename);
} else {
reject(new Error(`FFmpeg exited with code ${code}`));
}
});
ffmpeg.on('error', reject);
});
}
async function main() {
console.log('🎧 Stream Listener Started');
console.log(` Chunk duration: ${CHUNK_DURATION}s`);
console.log(` Trigger word: ${triggerWord || '(none - transcribing all)'}`);
console.log(' Press Ctrl+C to stop\n');
console.log('='.repeat(50));
let chunkNum = 0;
while (true) {
chunkNum++;
const timestamp = new Date().toLocaleTimeString();
try {
process.stdout.write(`\n[${timestamp}] Chunk #${chunkNum}: Recording...`);
const audioFile = await captureChunk(chunkNum);
process.stdout.write(' Analyzing...');
const result = await analyzeChunk(audioFile, chunkNum);
// Clean up chunk file
fs.unlinkSync(audioFile);
if (result.includes('SILENCE') || result.includes('NOTHING')) {
process.stdout.write(' (silence)');
} else if (result.includes('DETECTED')) {
console.log(`\n\n🚨 ${result}\n`);
} else {
console.log(`\n → "${result}"`);
}
} catch (err) {
console.log(` ERROR: ${err.message}`);
}
}
}
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\n\n👋 Listener stopped.');
process.exit(0);
});
main().catch(console.error);

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

View File

@ -0,0 +1,94 @@
# Buba Memory System — Build Spec for Claude Code
## Goal
Build a searchable memory index for Clawdbot (Buba) that indexes Discord messages and session transcripts, enabling semantic search over conversation history.
---
## Data Sources
1. **Discord messages** — accessible via Clawdbot's `message` tool (`action=search`, `action=read`)
2. **Session transcripts** — JSONL files in `~/.clawdbot/agents/main/sessions/`
3. **Existing memory files**`~/.clawdbot/workspace/memory/*.md` and `MEMORY.md`
---
## Core Requirements
### 1. Indexer
- Crawl Discord channels and extract messages (use Clawdbot's message tool or Discord API)
- Parse session JSONL files for conversation history
- Extract: timestamp, author, channel/source, content, message ID
- Chunk long conversations into searchable segments
- Generate embeddings for semantic search (OpenAI `text-embedding-3-small` or local)
### 2. Storage
- SQLite or similar for metadata + vector store
- Store in `~/.clawdbot/workspace/memory-index/` or similar
- Support incremental updates (track last indexed message/timestamp)
### 3. Search Interface
- Semantic search (query → top-k relevant messages/chunks)
- Filters: date range, channel, author
- Return: content, source, timestamp, relevance score
### 4. Clawdbot Integration
- Either: CLI tool Buba can call via `exec`
- Or: extend `memory_search` tool to query this index
- Output format Buba can parse and use in responses
---
## Nice-to-haves
- Auto-summarization of indexed content
- Entity extraction (projects, people, decisions)
- Deduplication across sources
- Time-aware queries ("what did we discuss last Tuesday")
---
## Tech Stack Suggestions
- Python (matches existing PageIndex framework in `~/.clawdbot/workspace/pageindex-framework/`)
- ChromaDB or FAISS for vector storage
- OpenAI embeddings or local alternative
---
## Test Data Available
- Session files: `~/.clawdbot/agents/main/sessions/*.jsonl`
- Discord history: accessible via Clawdbot message tool
- Existing memory: `~/.clawdbot/workspace/memory/`
---
## Why This Matters
Currently Buba relies on manually logging things to memory files, which means context gets lost if not explicitly written down. With this system, Buba can search actual conversation history and have much better recall of past decisions, preferences, and project context.
---
## Output Format Example (for search results)
```json
{
"query": "remix scoring algorithm",
"results": [
{
"content": "We decided to weight TikTok velocity at 2x for the remix scoring...",
"source": "discord",
"channel": "#general",
"author": "JakeShore",
"timestamp": "2026-01-15T14:32:00Z",
"message_id": "1234567890",
"score": 0.89
}
]
}
```
---
## File Locations Reference
- Clawdbot workspace: `~/.clawdbot/workspace/`
- Session transcripts: `~/.clawdbot/agents/main/sessions/`
- PageIndex framework: `~/.clawdbot/workspace/pageindex-framework/`
- Memory files: `~/.clawdbot/workspace/memory/`

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

BIN
clawdbot-book/slide-01.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
clawdbot-book/slide-02.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
clawdbot-book/slide-03.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
clawdbot-book/slide-04.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
clawdbot-book/slide-05.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
clawdbot-book/slide-06.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
clawdbot-book/slide-07.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
clawdbot-book/slide-08.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
clawdbot-book/slide-09.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
clawdbot-book/slide-10.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -0,0 +1,21 @@
{
"name": "clawdbot-calls-video",
"version": "1.0.0",
"description": "Promotional video for ClawdBot AI phone calls",
"scripts": {
"dev": "remotion studio",
"build": "remotion render src/index.tsx ClawdBotCalls out/clawdbot-calls.mp4",
"preview": "remotion render src/index.tsx ClawdBotCalls out/preview.mp4 --quality 50"
},
"dependencies": {
"@remotion/cli": "^4.0.0",
"@remotion/player": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remotion": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"typescript": "^5.0.0"
}
}

View File

@ -0,0 +1,4 @@
import { Config } from '@remotion/cli/config';
Config.setVideoImageFormat('jpeg');
Config.setOverwriteOutput(true);

View File

@ -0,0 +1,470 @@
import React from 'react';
import {
AbsoluteFill,
Sequence,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
Img,
staticFile,
Easing,
} from 'remotion';
// Colors
const BRAND_BLUE = '#0ea5e9';
const BRAND_PURPLE = '#8b5cf6';
const DARK_BG = '#0a0a0a';
// Screenshot data with zoom targets (5 screenshots now)
// Zoom target coordinates are relative to CENTER of phone mockup
// x: negative = left, positive = right
// y: negative = up, positive = down
const screenshots = [
{
file: 'screenshots/step1-command.png',
title: 'Give the Command',
subtitle: 'Start a call with a simple message',
// Zoom to the !call command and Buba response (lower middle of image)
zoomTarget: { x: 0, y: 0.15, scale: 2.0 },
},
{
file: 'screenshots/step2-call-started.png',
title: 'Call Connects',
subtitle: 'See the call status in real-time',
// Zoom to the green "Call Started" embed (upper portion)
zoomTarget: { x: 0, y: -0.18, scale: 2.2 },
},
{
file: 'screenshots/step3-live-chat.png',
title: 'Watch Live',
subtitle: 'Real-time transcription as it happens',
// Zoom to "Ask who his cats are" prompt (upper-middle area)
zoomTarget: { x: 0, y: -0.12, scale: 2.0 },
},
{
file: 'screenshots/step4-user-prompt.png',
title: 'Guide the AI',
subtitle: 'Send prompts mid-conversation',
// Zoom to "Ask why he's so awesome" prompt (middle area)
zoomTarget: { x: 0, y: 0.05, scale: 2.2 },
},
{
file: 'screenshots/step5-transcript.png',
title: 'Get the Transcript',
subtitle: 'Full record when the call ends',
// Zoom to the Full Transcript and Call Ended embed (lower area)
zoomTarget: { x: 0, y: 0.22, scale: 1.9 },
},
];
// Timing constants (in frames at 30fps)
const FRAMES_PER_SCREENSHOT = 150; // 5 seconds each
const ZOOM_IN_DURATION = 40; // 1.33s to zoom in
const ZOOM_HOLD_DURATION = 70; // 2.33s hold
const ZOOM_OUT_DURATION = 40; // 1.33s to zoom out
const OUTRO_START = screenshots.length * FRAMES_PER_SCREENSHOT;
const OUTRO_DURATION = 150; // 5 seconds
const TOTAL_DURATION = OUTRO_START + OUTRO_DURATION;
// Phone Mockup Component
const PhoneMockup: React.FC<{
screenshot: string;
index: number;
isActive: boolean;
activeProgress: number;
zoomTarget: { x: number; y: number; scale: number };
totalScreenshots: number;
}> = ({ screenshot, index, isActive, activeProgress, zoomTarget, totalScreenshots }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Calculate glow and lift based on active state
const glowIntensity = isActive
? interpolate(activeProgress, [0, 0.1, 0.9, 1], [0, 1, 1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' })
: 0;
const lift = isActive
? interpolate(activeProgress, [0, 0.1, 0.9, 1], [0, -25, -25, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' })
: 0;
const scale = isActive
? interpolate(activeProgress, [0, 0.1, 0.9, 1], [1, 1.08, 1.08, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' })
: 1;
// Phone dimensions - adjusted for 5 phones
const phoneWidth = 240;
const phoneHeight = 500;
const spacing = 30;
const totalWidth = totalScreenshots * phoneWidth + (totalScreenshots - 1) * spacing;
const startX = (1920 - totalWidth) / 2;
const x = startX + index * (phoneWidth + spacing);
return (
<div
style={{
position: 'absolute',
left: x,
top: '50%',
transform: `translateY(-50%) translateY(${lift}px) scale(${scale})`,
transition: 'transform 0.1s ease-out',
}}
>
{/* Glow effect */}
<div
style={{
position: 'absolute',
inset: -20,
borderRadius: 50,
background: `radial-gradient(ellipse at center, ${BRAND_BLUE}60 0%, transparent 70%)`,
opacity: glowIntensity,
filter: 'blur(20px)',
pointerEvents: 'none',
}}
/>
{/* Phone frame */}
<div
style={{
width: phoneWidth,
height: phoneHeight,
borderRadius: 28,
background: '#1a1a1a',
padding: 6,
boxShadow: isActive
? `0 20px 60px -10px rgba(0,0,0,0.8), 0 0 ${30 * glowIntensity}px ${BRAND_BLUE}50`
: '0 10px 40px -10px rgba(0,0,0,0.5)',
border: isActive ? `2px solid ${BRAND_BLUE}40` : '2px solid #333',
}}
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: 22,
overflow: 'hidden',
background: '#000',
}}
>
<Img
src={staticFile(screenshot)}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</div>
</div>
</div>
);
};
// Main Scene with all screenshots
const MainScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Determine which screenshot is active
const activeIndex = Math.min(
Math.floor(frame / FRAMES_PER_SCREENSHOT),
screenshots.length - 1
);
// Progress within current screenshot (0-1)
const progressInCurrent = (frame % FRAMES_PER_SCREENSHOT) / FRAMES_PER_SCREENSHOT;
// Get zoom parameters for active screenshot
const activeScreenshot = screenshots[activeIndex];
const { x: zoomX, y: zoomY, scale: zoomScale } = activeScreenshot.zoomTarget;
// Zoom timing within each screenshot segment
const localFrame = frame % FRAMES_PER_SCREENSHOT;
// Zoom in -> hold -> zoom out
const zoomProgress = interpolate(
localFrame,
[0, ZOOM_IN_DURATION, ZOOM_IN_DURATION + ZOOM_HOLD_DURATION, FRAMES_PER_SCREENSHOT],
[0, 1, 1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.cubic) }
);
// Calculate camera position based on active phone position
// Phone dimensions
const phoneWidth = 240;
const spacing = 30;
const totalWidth = screenshots.length * phoneWidth + (screenshots.length - 1) * spacing;
const startX = (1920 - totalWidth) / 2;
const activePhoneX = startX + activeIndex * (phoneWidth + spacing) + phoneWidth / 2;
// Camera offset from center of screen
const screenCenterX = 1920 / 2;
const targetCameraX = -(activePhoneX - screenCenterX);
// Camera transform - move camera to center on active phone, then zoom into detail
const cameraScale = interpolate(zoomProgress, [0, 1], [1, zoomScale]);
const cameraX = interpolate(zoomProgress, [0, 1], [0, targetCameraX + zoomX * 250]);
const cameraY = interpolate(zoomProgress, [0, 1], [0, zoomY * 350]);
// Title animation
const titleOpacity = interpolate(
localFrame,
[0, 20, FRAMES_PER_SCREENSHOT - 20, FRAMES_PER_SCREENSHOT],
[0, 1, 1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
);
const titleY = spring({
frame: localFrame,
fps,
from: 30,
to: 0,
durationInFrames: 30,
});
return (
<AbsoluteFill style={{ backgroundColor: DARK_BG }}>
{/* Subtle gradient background */}
<div
style={{
position: 'absolute',
inset: 0,
background: `radial-gradient(ellipse at 50% 100%, ${BRAND_BLUE}15 0%, transparent 50%)`,
}}
/>
{/* Camera container for zoom effects */}
<div
style={{
position: 'absolute',
inset: 0,
transform: `scale(${cameraScale}) translate(${cameraX}px, ${cameraY}px)`,
transformOrigin: 'center center',
}}
>
{/* All phone mockups */}
{screenshots.map((ss, index) => (
<PhoneMockup
key={index}
screenshot={ss.file}
index={index}
isActive={index === activeIndex}
activeProgress={index === activeIndex ? progressInCurrent : 0}
zoomTarget={ss.zoomTarget}
totalScreenshots={screenshots.length}
/>
))}
</div>
{/* Title overlay */}
<div
style={{
position: 'absolute',
bottom: 80,
left: 0,
right: 0,
textAlign: 'center',
opacity: titleOpacity,
transform: `translateY(${titleY}px)`,
}}
>
{/* Step indicator */}
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 10,
marginBottom: 16,
}}
>
{screenshots.map((_, i) => (
<div
key={i}
style={{
width: 32,
height: 4,
borderRadius: 2,
background: i === activeIndex ? BRAND_BLUE : '#333',
transition: 'background 0.3s',
}}
/>
))}
</div>
<h2
style={{
fontSize: 44,
fontWeight: 700,
color: 'white',
margin: 0,
fontFamily: 'Inter, system-ui, sans-serif',
}}
>
{activeScreenshot.title}
</h2>
<p
style={{
fontSize: 22,
color: '#a1a1aa',
margin: '10px 0 0',
fontFamily: 'Inter, system-ui, sans-serif',
}}
>
{activeScreenshot.subtitle}
</p>
</div>
{/* Header */}
<div
style={{
position: 'absolute',
top: 40,
left: 0,
right: 0,
textAlign: 'center',
}}
>
<h1
style={{
fontSize: 32,
fontWeight: 800,
background: `linear-gradient(135deg, ${BRAND_BLUE}, ${BRAND_PURPLE})`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
margin: 0,
fontFamily: 'Inter, system-ui, sans-serif',
}}
>
ClawdBot AI Phone Calls
</h1>
</div>
</AbsoluteFill>
);
};
// Outro Scene
const OutroScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: 'clamp' });
const scale = spring({ frame, fps, from: 0.9, to: 1, durationInFrames: 40 });
const ctaOpacity = interpolate(frame, [45, 75], [0, 1], { extrapolateRight: 'clamp' });
const ctaY = spring({ frame: Math.max(0, frame - 45), fps, from: 30, to: 0, durationInFrames: 30 });
return (
<AbsoluteFill style={{ backgroundColor: DARK_BG, justifyContent: 'center', alignItems: 'center' }}>
{/* Gradient background */}
<div
style={{
position: 'absolute',
inset: 0,
background: `linear-gradient(135deg, ${BRAND_BLUE}15 0%, ${BRAND_PURPLE}15 100%)`,
opacity,
}}
/>
<div
style={{
opacity,
transform: `scale(${scale})`,
textAlign: 'center',
}}
>
<h1
style={{
fontSize: 90,
fontWeight: 800,
background: `linear-gradient(135deg, ${BRAND_BLUE}, ${BRAND_PURPLE})`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
margin: 0,
fontFamily: 'Inter, system-ui, sans-serif',
}}
>
ClawdBot
</h1>
<p
style={{
fontSize: 32,
color: 'white',
marginTop: 16,
fontFamily: 'Inter, system-ui, sans-serif',
}}
>
Your AI that actually <em>does</em> things
</p>
{/* CTA */}
<div
style={{
marginTop: 50,
opacity: ctaOpacity,
transform: `translateY(${ctaY}px)`,
}}
>
<div
style={{
display: 'inline-block',
padding: '20px 48px',
background: `linear-gradient(135deg, ${BRAND_BLUE}, ${BRAND_PURPLE})`,
borderRadius: 16,
fontSize: 28,
fontWeight: 600,
color: 'white',
fontFamily: 'Inter, system-ui, sans-serif',
}}
>
Try it free at clawd.bot
</div>
</div>
{/* Feature pills */}
<div
style={{
marginTop: 40,
display: 'flex',
gap: 16,
justifyContent: 'center',
opacity: ctaOpacity,
}}
>
{['📞 AI Phone Calls', '👁️ Real-Time View', '💬 Guide Mid-Call'].map((text, i) => (
<div
key={i}
style={{
padding: '10px 20px',
background: '#1a1a1a',
borderRadius: 20,
border: '1px solid #333',
fontSize: 18,
color: '#a1a1aa',
fontFamily: 'Inter, system-ui, sans-serif',
}}
>
{text}
</div>
))}
</div>
</div>
</AbsoluteFill>
);
};
// Main Composition
export const ClawdBotCalls: React.FC = () => {
return (
<AbsoluteFill style={{ backgroundColor: DARK_BG }}>
{/* Main scene with all screenshots */}
<Sequence from={0} durationInFrames={OUTRO_START}>
<MainScene />
</Sequence>
{/* Outro */}
<Sequence from={OUTRO_START} durationInFrames={OUTRO_DURATION}>
<OutroScene />
</Sequence>
</AbsoluteFill>
);
};

View File

@ -0,0 +1,19 @@
import { registerRoot, Composition } from 'remotion';
import { ClawdBotCalls } from './ClawdBotCalls';
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="ClawdBotCalls"
component={ClawdBotCalls}
durationInFrames={900}
fps={30}
width={1920}
height={1080}
/>
</>
);
};
registerRoot(RemotionRoot);

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*"]
}

View File

@ -1,8 +1,9 @@
{ {
"mcpServers": { "mcpServers": {
"ghl": { "ghl": {
"description": "GoHighLevel MCP server with 461+ tools for CRM automation",
"command": "node", "command": "node",
"args": ["/Users/jakeshore/.clawdbot/workspace/GoHighLevel-MCP/dist/server.js"], "args": ["/Users/jakeshore/.clawdbot/workspace/mcp-diagrams/GoHighLevel-MCP/dist/server.js"],
"env": { "env": {
"GHL_API_KEY": "pit-0aebc49f-07f7-47dc-a494-181b72a1df54", "GHL_API_KEY": "pit-0aebc49f-07f7-47dc-a494-181b72a1df54",
"GHL_BASE_URL": "https://services.leadconnectorhq.com", "GHL_BASE_URL": "https://services.leadconnectorhq.com",
@ -10,16 +11,6 @@
"NODE_ENV": "production" "NODE_ENV": "production"
} }
}, },
"ghl-account": {
"command": "node",
"args": ["/Users/jakeshore/.clawdbot/workspace/GoHighLevel-MCP/dist/server.js"],
"env": {
"GHL_API_KEY": "pit-c666fb4c-04d5-47c6-8621-8d9d70463337",
"GHL_BASE_URL": "https://services.leadconnectorhq.com",
"GHL_LOCATION_ID": "DZEpRd43MxUJKdtrev9t",
"NODE_ENV": "production"
}
},
"yfinance-mcp": { "yfinance-mcp": {
"command": "npx", "command": "npx",
"args": ["yfinance-mcp"] "args": ["yfinance-mcp"]

0
create-discord-bot-v2.sh Normal file → Executable file
View File

1
cresync-landing Submodule

@ -0,0 +1 @@
Subproject commit fa84fb7a1ab5d1849de63af6fb1f247dee7b4a95

View File

@ -0,0 +1 @@
Take a step to do it. Yes. Yes! You did it! Let's get this to a mission. I didn't know all the feelings I could miss Until I looked inside my mind I've been lost since I don't know how to fit in my own skin Don't feel at home in this world I'm living in

View File

@ -0,0 +1 @@
There's no me, there's just us When I said it, I swear I meant it You were always enough I didn't mean to take you for granted You saved me from my broken soul We gave each other full control I left you crying on the road We both know that the night gets cold We gave each other full control We both know that the night gets cold You call me crazy, I'm just faded I'm escaping from your clutch I'm just chasing sweet sedation Using your heart as my crutch Self-inflated expectations Never met or overcome Now I'm jaded, complicated No surprise you chose to run You saved me from my broken soul We gave each other full control I left you crying on the road We both know that the night gets cold We gave each other full control We both know that the night gets cold You saved me from my broken soul We gave each other full control I left you crying on the road We both know that the night gets cold

View File

@ -0,0 +1 @@
I lost my heart, now I feel nothing It's been a while since I've felt something The blame cuts deep and thoughts start crushing Eyes open with self-destruction I've been so out of touch Mistaken kindness for affection, it's not love Ever been cursed with bad luck? Cause my stars don't align with my sun I lost my heart, now I feel nothing It's been a while since I've felt something The blame cuts deep and thoughts start crushing Eyes open with self-destruction

View File

@ -0,0 +1 @@
The teardrop in your eye Contains a single lethal dose You're lying by my side But I can't seem to find a pause The days keep passing by This love's a waste of time, you know Another sleepless night And I might need to overdose Please don't go Won't you stay? Please don't go Won't you stay? You've got me on my knees I'm begging for sweet relief Each time you go You break a piece off of my soul You cut me then stomp the blade And convince me you're all I need I'm seeing ghosts They've got their hands around my throat You keep on turning little nothings into somethings You throw in pillows slamming undeserving doors Your words cut deeper than a knife Can't even look me in the eye So please just go Please just go Don't you stay Please just go Don't you stay You've got me on my knees I'm begging for sweet relief Each time you go You break a piece off of my soul You cut me then stomp the blade And convince me you're all I need I'm seeing ghosts They've got their hands around my throat You cut me then stomp the blade And convince me you're all I need

View File

@ -0,0 +1 @@
To your right is the Baguio Botanical Garden To your left is the Baguio Botanical Garden To your right is the Baguio Botanical Garden To your right is the Baguio Botanical Garden To your left is the Baguio Botanical Garden To your right is the Baguio Botanical Garden To your left is the Baguio Botanical Garden To your right is the Baguio Botanical Garden To your left is the Baguio Botanical Garden To your right is the Baguio Botanical Garden To your left is the Baguio Botanical Garden To your right is the Baguio Botanical Garden

View File

@ -0,0 +1 @@
We are turning right to Session Road We are turning right to Session Road Are you still there? If so, we'd like to thank you for joining us in this drive. Thank you for joining us in this drive. Thank you for joining us in this drive. Thank you for joining us in this drive. Thank you for joining us in this drive. Thank you for joining us in this drive.

View File

@ -0,0 +1 @@
I'm falling I'm fa- I'm falling I'm fa- I'm fa- I'm falling I'm falling through the cracks Of all the plans I made inside my head I'm overwhelmed with fear and dread Now there's no going back I've turned down bridges with all of my friends I thought I'd have a happy end

View File

@ -0,0 +1 @@
I don't know how to sleep alone These drugs don't work on me no more I don't know how to sleep alone These drugs don't work on me no more I close my eyes, so far from home I fantasize, then overdose And my world comes tumbling down You sucked me dry, you drained my soul I'm void of life, I've lost control Now the quiet screams aloud I don't know how to sleep alone These drugs don't work on me no more I don't know how to sleep alone These drugs don't work on me no more I don't know how to sleep alone These drugs don't work on me no more I don't know how to sleep alone These drugs don't work on me no more

View File

@ -0,0 +1 @@
If only I could live that moment over again When the moonlight was shining, reflecting its light off your skin You told me when the cloudy days come back, don't stress Now that all stars collided, I'll be by your side till the end When I'm with you, life becomes less hard I'm floating through the stars, the world can do no harm Out of the blue, you caught me so off guard I can't control my heart, you're healing all my scars When I'm with you, when I'm with you Out of the blue, out of the blue When I'm with you, when I'm with you When I'm with you, life becomes less hard I'm floating through the stars, the world can do no harm Out of the blue, you caught me so off guard I can't control my heart, you're healing all my scars When I'm with you, life becomes less hard I'm floating through the stars, the world can do no harm Out of the blue, you caught me so off guard I can't control my heart, you're healing all my scars When I'm with you

View File

@ -0,0 +1 @@
I remember way back when We were young and reckless I was driving daddy's Benz You were oh so precious, for you You've been putting up with me for way too long I didn't mean to make you sad I was being selfish I don't wanna be like that But I just can't help it, for you Now I understand I treated you so wrong You left your cardigan And makeup on my bed You broke my heart again And went with words unsaid But for me, that day's replaying Your face is painted inside my mind Now I look back on when You and I first met Things were different then The worst hadn't come yet For me, that day's replaying The pain is growing inside my mind For you For me For you For me For you For me For you For me

View File

@ -0,0 +1 @@
ប៊រាំាកើប់គឺឯỔំញ ជចាំចាេះរួ឵្កាំចាំជៈ្ដ,ᕌាំនៅувати។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។ ប៊រាំចាំជ។

View File

@ -0,0 +1 @@
I don't know if I can give you all you deserve For the moment I'll just promise you my heart I know my emotions are up for discussion All my intentions can turn into nothing You're the reason why I do the things I do If the sky was falling I would run to you In the darkest night your light would guide me through If I could just hit rewind to a simpler time I would fly us to the moon If the sky was falling I would run to you You lived a life that gave me all my hopes and dreams Just give me some time and I'll do things you won't believe I know my emotions are up for discussion All my intentions can turn into nothing You're the reason why I do the things I do If the sky was falling I would run to you In the darkest night your light would guide me through If I could just hit rewind to a simpler time I would fly us to the moon If the sky was falling I would run to you

View File

@ -0,0 +1 @@
Deep down, it hurts Every night, I'm on my own I start to lose control You go, me first Now I'm learning as I go The more I see, the less I know The medications in my drawer Can't protect me from the war That's raging in my head The sun sets on the western front I'm getting flanked, I can't adjust This battle never ends Deep down, it hurts Every night, I'm on my own I start to lose control You go, me first Now I'm learning as I go The more I see, the less I know Now I'm learning as I go

View File

@ -0,0 +1 @@
Life's so hollow Without you You took my sorrow And flew it to the moon Life's so hollow Without you You took my sorrow And flew it to the moon Life's so hollow Without you You took my sorrow And flew it to the moon Life's so hollow Without you You took my sorrow And flew it to the moon Life's so hollow Without you You took my sorrow And flew it to the moon

View File

@ -0,0 +1,21 @@
<!doctype html>
<meta charset="utf-8" />
<title>openai-image-gen</title>
<style>
:root { color-scheme: dark; }
body { margin: 24px; font: 14px/1.4 ui-sans-serif, system-ui; background: #0b0f14; color: #e8edf2; }
h1 { font-size: 18px; margin: 0 0 16px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
figure { margin: 0; padding: 12px; border: 1px solid #1e2a36; border-radius: 14px; background: #0f1620; }
img { width: 100%; height: auto; border-radius: 10px; display: block; }
figcaption { margin-top: 10px; color: #b7c2cc; }
code { color: #9cd1ff; }
</style>
<h1>openai-image-gen</h1>
<p>Output: <code>.</code></p>
<div class="grid">
<figure>
<a href="001-abstract-sunrise-art-massive-golden-sun-.png"><img src="001-abstract-sunrise-art-massive-golden-sun-.png" loading="lazy" /></a>
<figcaption>Abstract sunrise art, massive golden sun rising over abstract horizon, warm orange and gold rays flooding everything, tiny figure silhouette standing in light, feeling of peace and arrival, SURYA (sun) in full glory, soft gradients, hopeful triumphant ending</figcaption>
</figure>
</div>

View File

@ -0,0 +1,6 @@
[
{
"prompt": "Abstract sunrise art, massive golden sun rising over abstract horizon, warm orange and gold rays flooding everything, tiny figure silhouette standing in light, feeling of peace and arrival, SURYA (sun) in full glory, soft gradients, hopeful triumphant ending",
"file": "001-abstract-sunrise-art-massive-golden-sun-.png"
}
]

669
das-website/index.html Normal file
View File

@ -0,0 +1,669 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Das — SURYA</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300;1,400&family=Space+Grotesk:wght@300;400&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html {
scroll-behavior: smooth;
scrollbar-width: none;
}
html::-webkit-scrollbar { display: none; }
body {
font-family: 'Cormorant Garamond', serif;
background: #030305;
color: white;
overflow-x: hidden;
}
/* ========== THE CONTINUOUS WORLD ========== */
#world {
position: fixed;
inset: 0;
z-index: 1;
}
/* Gradient background that shifts with scroll */
#gradient-bg {
position: absolute;
inset: 0;
transition: background 0.5s ease;
}
/* Organic blob shapes */
.blob {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
transition: all 1s ease;
}
#blob1 {
width: 50vw;
height: 50vw;
top: -10%;
left: -10%;
}
#blob2 {
width: 40vw;
height: 40vw;
bottom: -10%;
right: -10%;
}
#blob3 {
width: 30vw;
height: 30vw;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* The soul orb - persistent element */
#soul {
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
background: radial-gradient(circle, white 0%, rgba(255,255,255,0.5) 50%, transparent 70%);
box-shadow: 0 0 60px 20px rgba(255,255,255,0.3);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
transition: all 0.8s ease;
z-index: 10;
}
#soul::after {
content: '';
position: absolute;
inset: -20px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.2);
animation: soul-ring 3s ease-in-out infinite;
}
@keyframes soul-ring {
0%, 100% { transform: scale(1); opacity: 0.5; }
50% { transform: scale(1.5); opacity: 0; }
}
/* Second soul (appears during "with u") */
#soul2 {
position: absolute;
width: 15px;
height: 15px;
border-radius: 50%;
background: radial-gradient(circle, #fbbf24 0%, rgba(251,191,36,0.5) 50%, transparent 70%);
box-shadow: 0 0 40px 15px rgba(251,191,36,0.3);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: all 1s ease;
z-index: 10;
}
/* Particle system */
#particles {
position: absolute;
inset: 0;
overflow: hidden;
}
.particle {
position: absolute;
width: 2px;
height: 2px;
border-radius: 50%;
background: rgba(255,255,255,0.4);
animation: float-up 15s linear infinite;
}
@keyframes float-up {
0% { transform: translateY(100vh) translateX(0) scale(0); opacity: 0; }
10% { opacity: 1; transform: translateY(90vh) translateX(10px) scale(1); }
90% { opacity: 1; }
100% { transform: translateY(-10vh) translateX(-10px) scale(0.5); opacity: 0; }
}
/* Wave/ripple effect */
.ripple {
position: absolute;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.1);
animation: ripple-expand 4s ease-out infinite;
}
@keyframes ripple-expand {
0% { width: 0; height: 0; opacity: 0.5; }
100% { width: 100vw; height: 100vw; opacity: 0; }
}
/* Stars (appear later in journey) */
.star {
position: absolute;
width: 3px;
height: 3px;
background: white;
border-radius: 50%;
opacity: 0;
transition: opacity 1s;
}
/* ========== CONTENT LAYER ========== */
#content {
position: relative;
z-index: 100;
}
.scene {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.scene-inner {
text-align: center;
padding: 2rem;
max-width: 700px;
opacity: 0;
transform: translateY(30px);
transition: opacity 0.8s ease, transform 0.8s ease;
}
.scene.visible .scene-inner {
opacity: 1;
transform: translateY(0);
}
.track-num {
font-family: 'Space Grotesk', sans-serif;
font-size: 0.7rem;
letter-spacing: 0.4em;
color: rgba(255,255,255,0.25);
margin-bottom: 1rem;
}
.track-title {
font-size: clamp(2.5rem, 8vw, 5rem);
font-weight: 300;
font-style: italic;
line-height: 1;
margin-bottom: 1.5rem;
transition: color 0.5s;
}
.track-lyric {
font-size: clamp(1rem, 2vw, 1.3rem);
font-weight: 300;
font-style: italic;
color: rgba(255,255,255,0.6);
line-height: 2;
}
/* Intro special */
.intro-title {
font-size: clamp(4rem, 15vw, 10rem);
font-weight: 300;
letter-spacing: 0.15em;
margin-bottom: 1rem;
}
.intro-sub {
font-size: 1.1rem;
color: rgba(255,255,255,0.4);
letter-spacing: 0.1em;
}
.intro-artist {
font-family: 'Space Grotesk', sans-serif;
font-size: 0.75rem;
letter-spacing: 0.3em;
color: rgba(255,255,255,0.2);
margin-top: 2rem;
}
.scroll-hint {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
text-align: center;
}
.scroll-hint span {
font-family: 'Space Grotesk', sans-serif;
font-size: 0.65rem;
letter-spacing: 0.2em;
color: rgba(255,255,255,0.2);
}
.scroll-line {
width: 1px;
height: 40px;
background: linear-gradient(180deg, rgba(255,255,255,0.3), transparent);
margin: 10px auto 0;
animation: scroll-pulse 2s ease-in-out infinite;
}
@keyframes scroll-pulse {
0%, 100% { opacity: 0.3; height: 40px; }
50% { opacity: 0.8; height: 50px; }
}
/* Finale */
.finale-inner {
text-align: center;
}
.finale-title {
font-size: clamp(1.8rem, 4vw, 2.5rem);
font-weight: 300;
font-style: italic;
margin-bottom: 2.5rem;
}
.platform-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.8rem;
margin-bottom: 1.5rem;
}
.platform-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.2rem;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 50px;
color: white;
text-decoration: none;
font-family: 'Space Grotesk', sans-serif;
font-size: 0.8rem;
transition: all 0.3s;
}
.platform-btn:hover {
background: rgba(251,191,36,0.1);
border-color: rgba(251,191,36,0.3);
transform: translateY(-2px);
}
.platform-btn svg {
width: 16px;
height: 16px;
fill: currentColor;
}
.social-row {
display: flex;
justify-content: center;
gap: 0.8rem;
margin-top: 1.5rem;
}
.social-btn {
width: 40px;
height: 40px;
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255,255,255,0.4);
text-decoration: none;
transition: all 0.3s;
}
.social-btn:hover {
background: rgba(251,191,36,0.2);
color: white;
}
.social-btn svg {
width: 16px;
height: 16px;
fill: currentColor;
}
.credit {
margin-top: 3rem;
font-family: 'Space Grotesk', sans-serif;
font-size: 0.6rem;
color: rgba(255,255,255,0.15);
letter-spacing: 0.1em;
}
/* Progress indicator */
#progress {
position: fixed;
left: 20px;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 50vh;
background: rgba(255,255,255,0.05);
z-index: 200;
border-radius: 1px;
}
#progress-fill {
width: 100%;
background: linear-gradient(180deg, #a78bfa, #fbbf24);
border-radius: 1px;
transition: height 0.1s;
}
@media (max-width: 600px) {
#progress { display: none; }
.scene-inner { padding: 1.5rem; }
}
</style>
</head>
<body>
<!-- Progress -->
<div id="progress">
<div id="progress-fill" style="height: 0%"></div>
</div>
<!-- THE CONTINUOUS WORLD -->
<div id="world">
<div id="gradient-bg"></div>
<div class="blob" id="blob1"></div>
<div class="blob" id="blob2"></div>
<div class="blob" id="blob3"></div>
<div id="particles"></div>
<div id="ripples"></div>
<div id="stars"></div>
<div id="soul"></div>
<div id="soul2"></div>
</div>
<!-- CONTENT -->
<div id="content">
<!-- Intro -->
<section class="scene" data-phase="intro">
<div class="scene-inner">
<h1 class="intro-title">SURYA</h1>
<p class="intro-sub">a journey through feeling</p>
<p class="intro-artist">DAS</p>
</div>
<div class="scroll-hint">
<span>SCROLL</span>
<div class="scroll-line"></div>
</div>
</section>
<!-- 01 Skin -->
<section class="scene" data-phase="skin">
<div class="scene-inner">
<p class="track-num">01</p>
<h2 class="track-title">skin</h2>
<p class="track-lyric">"I don't know how to fit in my own skin<br>don't feel at home in this world I'm living in"</p>
</div>
</section>
<!-- 02 U Saved Me -->
<section class="scene" data-phase="saved">
<div class="scene-inner">
<p class="track-num">02</p>
<h2 class="track-title">u saved me</h2>
<p class="track-lyric">"you saved me from my broken soul<br>we gave each other full control"</p>
</div>
</section>
<!-- 03 Nothing -->
<section class="scene" data-phase="nothing">
<div class="scene-inner">
<p class="track-num">03</p>
<h2 class="track-title">nothing</h2>
<p class="track-lyric">"I lost my heart, now I feel nothing<br>it's been a while since I've felt something"</p>
</div>
</section>
<!-- 04 Sweet Relief -->
<section class="scene" data-phase="relief">
<div class="scene-inner">
<p class="track-num">04</p>
<h2 class="track-title">sweet relief</h2>
<p class="track-lyric">"I'm seeing ghosts<br>they've got their hands around my throat"</p>
</div>
</section>
<!-- 05-06 Nature -->
<section class="scene" data-phase="nature">
<div class="scene-inner">
<p class="track-num">05—06</p>
<h2 class="track-title">nature's call</h2>
<p class="track-lyric">"thank you for joining us on this drive"</p>
</div>
</section>
<!-- 07 Dreamcatcher -->
<section class="scene" data-phase="dream">
<div class="scene-inner">
<p class="track-num">07</p>
<h2 class="track-title">dreamcatcher</h2>
<p class="track-lyric">"I'm falling through the cracks<br>of all the plans I made inside my head"</p>
</div>
</section>
<!-- 08 IDK -->
<section class="scene" data-phase="idk">
<div class="scene-inner">
<p class="track-num">08</p>
<h2 class="track-title">idk</h2>
<p class="track-lyric">"I don't know how to sleep alone<br>these drugs don't work on me no more"</p>
</div>
</section>
<!-- 09 With U (THE TURN) -->
<section class="scene" data-phase="with">
<div class="scene-inner">
<p class="track-num">09</p>
<h2 class="track-title">with u</h2>
<p class="track-lyric">"when I'm with you, life becomes less hard<br>I'm floating through the stars"</p>
</div>
</section>
<!-- 10 Poor You Poor Me -->
<section class="scene" data-phase="poor">
<div class="scene-inner">
<p class="track-num">10</p>
<h2 class="track-title">poor you poor me</h2>
<p class="track-lyric">"you left your cardigan and makeup on my bed"</p>
</div>
</section>
<!-- 11-12 Run -->
<section class="scene" data-phase="run">
<div class="scene-inner">
<p class="track-num">11—12</p>
<h2 class="track-title">run to u</h2>
<p class="track-lyric">"if the sky was falling<br>I would run to you"</p>
</div>
</section>
<!-- 13 Medications -->
<section class="scene" data-phase="meds">
<div class="scene-inner">
<p class="track-num">13</p>
<h2 class="track-title">medications</h2>
<p class="track-lyric">"the medications in my drawer<br>can't protect me from the war<br>that's raging in my head"</p>
</div>
</section>
<!-- 14 Hollow -->
<section class="scene" data-phase="hollow">
<div class="scene-inner">
<p class="track-num">14</p>
<h2 class="track-title">hollow</h2>
<p class="track-lyric">"life's so hollow without you<br>you took my sorrow and flew it to the moon"</p>
</div>
</section>
<!-- Finale -->
<section class="scene" data-phase="finale">
<div class="scene-inner finale-inner">
<h2 class="finale-title">ready to feel something?</h2>
<div class="platform-row">
<a href="#" class="platform-btn"><svg viewBox="0 0 24 24"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>Spotify</a>
<a href="#" class="platform-btn"><svg viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>YouTube</a>
<a href="#" class="platform-btn"><svg viewBox="0 0 24 24"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.6 0 12 0z"/></svg>SoundCloud</a>
</div>
<div class="social-row">
<a href="#" class="social-btn"><svg viewBox="0 0 24 24"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4z"/></svg></a>
<a href="#" class="social-btn"><svg viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg></a>
<a href="#" class="social-btn"><svg viewBox="0 0 24 24"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg></a>
</div>
<p class="credit">SURYA — DAS — 2026</p>
</div>
</section>
</div>
<script>
// ========== WORLD STATE ==========
const phases = {
intro: { bg: 'radial-gradient(ellipse at 50% 80%, #1a0a2e 0%, #0a0510 100%)', blob1: '#4a1a6e', blob2: '#1a0a3e', blob3: '#2a1a4e', soul: { scale: 1, glow: 'rgba(255,255,255,0.3)' }, title: '#ffffff' },
skin: { bg: 'radial-gradient(ellipse at 30% 70%, #2a0a3e 0%, #0a0515 100%)', blob1: '#6a2a8e', blob2: '#2a0a4e', blob3: '#4a1a6e', soul: { scale: 0.8, glow: 'rgba(244,114,182,0.4)' }, title: '#f472b6' },
saved: { bg: 'radial-gradient(ellipse at 70% 50%, #0a1a3e 0%, #050a1a 100%)', blob1: '#1a4a7e', blob2: '#0a2a5e', blob3: '#2a3a6e', soul: { scale: 1.2, glow: 'rgba(34,211,238,0.4)' }, title: '#22d3ee' },
nothing: { bg: 'radial-gradient(ellipse at 50% 50%, #1a1a1a 0%, #0a0a0a 100%)', blob1: '#2a2a2a', blob2: '#1a1a1a', blob3: '#151515', soul: { scale: 0.5, glow: 'rgba(100,100,100,0.2)' }, title: '#666666' },
relief: { bg: 'radial-gradient(ellipse at 40% 60%, #1a0a2a 0%, #0a0515 100%)', blob1: '#4a2a6e', blob2: '#2a1a4e', blob3: '#3a1a5e', soul: { scale: 0.7, glow: 'rgba(167,139,250,0.4)' }, title: '#a78bfa' },
nature: { bg: 'radial-gradient(ellipse at 50% 70%, #0a2a1a 0%, #050f0a 100%)', blob1: '#1a5a3a', blob2: '#0a3a2a', blob3: '#2a4a3a', soul: { scale: 1, glow: 'rgba(94,234,212,0.4)' }, title: '#5eead4' },
dream: { bg: 'radial-gradient(ellipse at 60% 40%, #1a1a3a 0%, #0a0a1a 100%)', blob1: '#3a3a7e', blob2: '#2a2a5e', blob3: '#4a4a6e', soul: { scale: 0.6, glow: 'rgba(196,181,253,0.4)' }, title: '#c4b5fd' },
idk: { bg: 'radial-gradient(ellipse at 50% 50%, #2a1a2a 0%, #0f0a0f 100%)', blob1: '#4a2a4a', blob2: '#2a1a2a', blob3: '#3a2a3a', soul: { scale: 0.4, glow: 'rgba(244,114,182,0.3)' }, title: '#f472b6' },
with: { bg: 'radial-gradient(ellipse at 50% 50%, #1a2a4a 0%, #0a1020 100%)', blob1: '#3a5a8e', blob2: '#2a4a7e', blob3: '#4a6a9e', soul: { scale: 1.5, glow: 'rgba(251,191,36,0.5)' }, title: '#fbbf24', showSoul2: true, showStars: true },
poor: { bg: 'radial-gradient(ellipse at 60% 50%, #2a1a1a 0%, #100a0a 100%)', blob1: '#5a3a2a', blob2: '#3a2a1a', blob3: '#4a3a2a', soul: { scale: 1, glow: 'rgba(251,146,60,0.4)' }, title: '#fb923c' },
run: { bg: 'radial-gradient(ellipse at 70% 40%, #0a2a3a 0%, #051520 100%)', blob1: '#2a5a7a', blob2: '#1a4a6a', blob3: '#3a6a8a', soul: { scale: 1.3, glow: 'rgba(34,211,238,0.5)' }, title: '#22d3ee' },
meds: { bg: 'radial-gradient(ellipse at 50% 60%, #2a0a0a 0%, #100505 100%)', blob1: '#5a1a1a', blob2: '#3a0a0a', blob3: '#4a1515', soul: { scale: 0.8, glow: 'rgba(239,68,68,0.4)' }, title: '#ef4444' },
hollow: { bg: 'radial-gradient(ellipse at 50% 30%, #2a2a1a 0%, #0f0f0a 100%)', blob1: '#5a5a2a', blob2: '#3a3a1a', blob3: '#4a4a2a', soul: { scale: 1, glow: 'rgba(251,191,36,0.4)' }, title: '#fbbf24' },
finale: { bg: 'radial-gradient(ellipse at 50% 100%, #3a2a0a 0%, #1a1005 50%, #0a0a05 100%)', blob1: '#7a5a1a', blob2: '#5a4a1a', blob3: '#6a5a2a', soul: { scale: 2, glow: 'rgba(251,191,36,0.6)' }, title: '#fbbf24', showStars: true }
};
// Elements
const gradientBg = document.getElementById('gradient-bg');
const blob1 = document.getElementById('blob1');
const blob2 = document.getElementById('blob2');
const blob3 = document.getElementById('blob3');
const soul = document.getElementById('soul');
const soul2 = document.getElementById('soul2');
const particlesEl = document.getElementById('particles');
const starsEl = document.getElementById('stars');
const progressFill = document.getElementById('progress-fill');
const scenes = document.querySelectorAll('.scene');
// Create particles
for (let i = 0; i < 40; i++) {
const p = document.createElement('div');
p.className = 'particle';
p.style.left = Math.random() * 100 + '%';
p.style.animationDelay = Math.random() * 15 + 's';
p.style.animationDuration = (12 + Math.random() * 8) + 's';
particlesEl.appendChild(p);
}
// Create stars (hidden initially)
for (let i = 0; i < 60; i++) {
const s = document.createElement('div');
s.className = 'star';
s.style.left = Math.random() * 100 + '%';
s.style.top = Math.random() * 100 + '%';
s.style.animationDelay = Math.random() * 3 + 's';
if (Math.random() > 0.7) {
s.style.width = '4px';
s.style.height = '4px';
}
starsEl.appendChild(s);
}
const stars = starsEl.querySelectorAll('.star');
// Current phase
let currentPhase = 'intro';
function updateWorld(phase) {
if (phase === currentPhase) return;
currentPhase = phase;
const p = phases[phase];
if (!p) return;
// Background
gradientBg.style.background = p.bg;
// Blobs
blob1.style.background = p.blob1;
blob2.style.background = p.blob2;
blob3.style.background = p.blob3;
// Soul
soul.style.transform = `translate(-50%, -50%) scale(${p.soul.scale})`;
soul.style.boxShadow = `0 0 ${60 * p.soul.scale}px ${20 * p.soul.scale}px ${p.soul.glow}`;
// Soul 2 (for "with u")
if (p.showSoul2) {
soul2.style.opacity = '1';
soul2.style.transform = 'translate(calc(-50% + 40px), calc(-50% - 20px))';
} else {
soul2.style.opacity = '0';
soul2.style.transform = 'translate(-50%, -50%)';
}
// Stars
stars.forEach(s => {
s.style.opacity = p.showStars ? (0.3 + Math.random() * 0.7) : '0';
});
// Title colors
const title = document.querySelector(`[data-phase="${phase}"] .track-title`);
if (title) title.style.color = p.title;
}
// Intersection Observer
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
const phase = entry.target.dataset.phase;
updateWorld(phase);
}
});
}, { threshold: 0.5 });
scenes.forEach(s => observer.observe(s));
// Scroll progress
window.addEventListener('scroll', () => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = (scrollTop / docHeight) * 100;
progressFill.style.height = progress + '%';
// Subtle blob movement based on scroll
const moveAmount = scrollTop * 0.02;
blob1.style.transform = `translate(${Math.sin(moveAmount) * 20}px, ${Math.cos(moveAmount) * 20}px)`;
blob2.style.transform = `translate(${Math.cos(moveAmount) * 15}px, ${Math.sin(moveAmount) * 15}px)`;
});
// Initial state
scenes[0].classList.add('visible');
updateWorld('intro');
console.log('☀️ SURYA — one continuous journey');
</script>
</body>
</html>

301
fix-clawdbot-permissions.sh Executable file
View File

@ -0,0 +1,301 @@
#!/bin/bash
#
# ClawdBot Permission Hardening Script
# Generated: 2026-01-27
# Purpose: Fix file permissions in ~/.clawdbot for security hardening
#
# Permission Guidelines:
# - Directories: 700 (owner only)
# - Databases (.sqlite): 600 (owner read/write only)
# - Config files with secrets: 600
# - Scripts (.sh): 700 (owner execute only)
# - General files: 600 (no world-readable)
# - All files owned by jakeshore:staff
#
set -e
CLAWDBOT_DIR="$HOME/.clawdbot"
LOG_FILE="$CLAWDBOT_DIR/workspace/permission-fix-$(date +%Y%m%d-%H%M%S).log"
echo "ClawdBot Permission Hardening Script"
echo "====================================="
echo "Logging to: $LOG_FILE"
echo ""
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
fix_ownership() {
local path="$1"
local current_owner=$(stat -f "%Su:%Sg" "$path" 2>/dev/null)
if [ "$current_owner" != "jakeshore:staff" ]; then
log "FIXING OWNERSHIP: $path ($current_owner -> jakeshore:staff)"
chown jakeshore:staff "$path"
fi
}
fix_permission() {
local path="$1"
local desired_perm="$2"
local current_perm=$(stat -f "%OLp" "$path" 2>/dev/null)
if [ "$current_perm" != "$desired_perm" ]; then
log "FIXING PERMISSION: $path ($current_perm -> $desired_perm)"
chmod "$desired_perm" "$path"
fi
}
echo "========================================"
echo "PHASE 1: Fix file ownership (wheel -> staff)"
echo "========================================"
# Fix clawdbot.json which has wheel group
log "Checking for files with incorrect group ownership..."
fix_ownership "$CLAWDBOT_DIR/clawdbot.json"
echo ""
echo "========================================"
echo "PHASE 2: Harden top-level directory"
echo "========================================"
# Root clawdbot directory should be 700
fix_permission "$CLAWDBOT_DIR" "700"
echo ""
echo "========================================"
echo "PHASE 3: Harden sensitive directories"
echo "========================================"
# These directories contain sensitive data - should be 700
SENSITIVE_DIRS=(
"$CLAWDBOT_DIR/agents"
"$CLAWDBOT_DIR/agents/main"
"$CLAWDBOT_DIR/agents/main/agent"
"$CLAWDBOT_DIR/agents/main/sessions"
"$CLAWDBOT_DIR/agents/imessage"
"$CLAWDBOT_DIR/agents/imessage/agent"
"$CLAWDBOT_DIR/agents/imessage/sessions"
"$CLAWDBOT_DIR/identity"
"$CLAWDBOT_DIR/memory"
"$CLAWDBOT_DIR/devices"
"$CLAWDBOT_DIR/subagents"
"$CLAWDBOT_DIR/tools"
"$CLAWDBOT_DIR/logs"
"$CLAWDBOT_DIR/cron"
"$CLAWDBOT_DIR/cron/runs"
"$CLAWDBOT_DIR/nodes"
"$CLAWDBOT_DIR/skills"
"$CLAWDBOT_DIR/browser"
"$CLAWDBOT_DIR/browser/clawd"
"$CLAWDBOT_DIR/browser/clawd/user-data"
"$CLAWDBOT_DIR/backups"
"$CLAWDBOT_DIR/lib"
"$CLAWDBOT_DIR/media"
"$CLAWDBOT_DIR/workspace"
)
for dir in "${SENSITIVE_DIRS[@]}"; do
if [ -d "$dir" ]; then
fix_permission "$dir" "700"
fi
done
echo ""
echo "========================================"
echo "PHASE 4: Harden databases (600)"
echo "========================================"
# SQLite databases - must be 600
while IFS= read -r -d '' dbfile; do
fix_permission "$dbfile" "600"
done < <(find "$CLAWDBOT_DIR" -type f \( -name "*.sqlite" -o -name "*.sqlite3" -o -name "*.db" \) -print0 2>/dev/null)
echo ""
echo "========================================"
echo "PHASE 5: Harden sensitive config files (600)"
echo "========================================"
# Sensitive config files that may contain secrets/tokens
SENSITIVE_FILES=(
"$CLAWDBOT_DIR/clawdbot.json"
"$CLAWDBOT_DIR/clawdbot.json.bak"
"$CLAWDBOT_DIR/clawdbot.json.bak.1"
"$CLAWDBOT_DIR/clawdbot.json.bak.2"
"$CLAWDBOT_DIR/clawdbot.json.bak.3"
"$CLAWDBOT_DIR/clawdbot.json.bak.4"
"$CLAWDBOT_DIR/clawdbot.json.backup-20260113-102502"
"$CLAWDBOT_DIR/clawdbot.json.backup-20260114-192759"
"$CLAWDBOT_DIR/clawdbot.json.backup-20260125-012611"
"$CLAWDBOT_DIR/identity/device.json"
"$CLAWDBOT_DIR/identity/device-auth.json"
"$CLAWDBOT_DIR/devices/paired.json"
"$CLAWDBOT_DIR/devices/pending.json"
"$CLAWDBOT_DIR/subagents/runs.json"
"$CLAWDBOT_DIR/nodes/paired.json"
"$CLAWDBOT_DIR/nodes/pending.json"
"$CLAWDBOT_DIR/cron/jobs.json"
"$CLAWDBOT_DIR/cron/jobs.json.bak"
"$CLAWDBOT_DIR/update-check.json"
"$CLAWDBOT_DIR/gateway.25337ee8.lock"
)
for file in "${SENSITIVE_FILES[@]}"; do
if [ -f "$file" ]; then
fix_permission "$file" "600"
fi
done
# Auth profile files in agents directories
while IFS= read -r -d '' authfile; do
fix_permission "$authfile" "600"
done < <(find "$CLAWDBOT_DIR/agents" -type f -name "auth-profiles.json" -print0 2>/dev/null)
# Session index files
while IFS= read -r -d '' sessfile; do
fix_permission "$sessfile" "600"
done < <(find "$CLAWDBOT_DIR/agents" -type f -name "sessions.json" -print0 2>/dev/null)
echo ""
echo "========================================"
echo "PHASE 6: Harden session logs (600)"
echo "========================================"
# Session log files may contain sensitive conversation data
while IFS= read -r -d '' sesslog; do
fix_permission "$sesslog" "600"
done < <(find "$CLAWDBOT_DIR/agents" -type f -name "*.jsonl" -print0 2>/dev/null)
while IFS= read -r -d '' sesslog; do
fix_permission "$sesslog" "600"
done < <(find "$CLAWDBOT_DIR/agents" -type f -name "*.jsonl.deleted.*" -print0 2>/dev/null)
# Cron run logs
while IFS= read -r -d '' cronlog; do
fix_permission "$cronlog" "600"
done < <(find "$CLAWDBOT_DIR/cron/runs" -type f -name "*.jsonl" -print0 2>/dev/null)
echo ""
echo "========================================"
echo "PHASE 7: Harden log files (600)"
echo "========================================"
while IFS= read -r -d '' logfile; do
fix_permission "$logfile" "600"
done < <(find "$CLAWDBOT_DIR/logs" -type f -print0 2>/dev/null)
echo ""
echo "========================================"
echo "PHASE 8: Harden shell scripts (700)"
echo "========================================"
# Shell scripts should be 700 (owner execute only)
SCRIPTS=(
"$CLAWDBOT_DIR/backup-to-icloud.sh"
)
for script in "${SCRIPTS[@]}"; do
if [ -f "$script" ]; then
fix_permission "$script" "700"
fi
done
echo ""
echo "========================================"
echo "PHASE 9: Harden backup directories"
echo "========================================"
# Recursively fix backup directory
while IFS= read -r -d '' backupdir; do
fix_permission "$backupdir" "700"
done < <(find "$CLAWDBOT_DIR/backups" -type d -print0 2>/dev/null)
while IFS= read -r -d '' backupfile; do
fix_permission "$backupfile" "600"
done < <(find "$CLAWDBOT_DIR/backups" -type f -print0 2>/dev/null)
echo ""
echo "========================================"
echo "PHASE 10: Verify symlinks"
echo "========================================"
log "Checking symlinks for potential security issues..."
# Check skills symlinks - they point to ../../.agents which is outside clawdbot
# This is a potential concern as it points to user home directory
SYMLINKS_OUTSIDE=(
"$CLAWDBOT_DIR/skills/remotion-best-practices"
"$CLAWDBOT_DIR/skills/threejs-animation"
"$CLAWDBOT_DIR/skills/threejs-fundamentals"
"$CLAWDBOT_DIR/skills/threejs-geometry"
"$CLAWDBOT_DIR/skills/threejs-interaction"
"$CLAWDBOT_DIR/skills/threejs-lighting"
)
log ""
log "WARNING: The following symlinks point outside ~/.clawdbot:"
for link in "${SYMLINKS_OUTSIDE[@]}"; do
if [ -L "$link" ]; then
target=$(readlink "$link")
resolved=$(cd "$(dirname "$link")" && cd "$(dirname "$target")" 2>/dev/null && pwd)/$(basename "$target")
log " $link -> $target"
log " Resolves to: $resolved"
fi
done
log ""
log "RECOMMENDATION: Consider copying these files into ~/.clawdbot/skills/ instead of symlinks"
log "to contain all sensitive data within the protected directory structure."
# Check node symlink (internal, acceptable)
if [ -L "$CLAWDBOT_DIR/tools/node" ]; then
target=$(readlink "$CLAWDBOT_DIR/tools/node")
log "OK: tools/node symlink is internal -> $target"
fi
echo ""
echo "========================================"
echo "PHASE 11: Final verification"
echo "========================================"
log ""
log "Checking for any remaining world-readable files..."
world_readable=$(find "$CLAWDBOT_DIR" -maxdepth 3 -type f -perm -004 2>/dev/null | grep -v node_modules | head -20)
if [ -n "$world_readable" ]; then
log "WARNING: These files are still world-readable:"
echo "$world_readable" | while read -r f; do
log " $f"
done
else
log "OK: No world-readable files found in top 3 levels (excluding node_modules)"
fi
log ""
log "Checking for any remaining world-executable directories..."
world_exec=$(find "$CLAWDBOT_DIR" -maxdepth 3 -type d -perm -005 2>/dev/null | grep -v node_modules | head -20)
if [ -n "$world_exec" ]; then
log "WARNING: These directories are still world-accessible:"
echo "$world_exec" | while read -r d; do
log " $d"
done
else
log "OK: No world-accessible directories found in top 3 levels (excluding node_modules)"
fi
echo ""
echo "========================================"
echo "SUMMARY"
echo "========================================"
log ""
log "Permission hardening complete!"
log "Log file: $LOG_FILE"
log ""
log "Post-hardening checklist:"
log " 1. Verify clawdbot still functions correctly"
log " 2. Consider replacing symlinks with actual files"
log " 3. Review any .env or credentials files in workspace/"
log " 4. Ensure backups are stored securely"
log ""
log "Run 'cat $LOG_FILE' to see all changes made."
echo ""
echo "Done!"

View File

@ -43,7 +43,13 @@
.panel h3 { font-size: 0.7rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 12px; color: rgba(255,255,255,0.4); } .panel h3 { font-size: 0.7rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 12px; color: rgba(255,255,255,0.4); }
.legend-item { display: flex; align-items: center; margin-bottom: 6px; font-size: 0.7rem; color: rgba(255,255,255,0.6); padding: 3px 6px; margin-left: -6px; border-radius: 4px; cursor: pointer; transition: all 0.2s; } .legend-item { display: flex; align-items: center; margin-bottom: 6px; font-size: 0.7rem; color: rgba(255,255,255,0.6); padding: 3px 6px; margin-left: -6px; border-radius: 4px; cursor: pointer; transition: all 0.2s; }
.legend-item:hover { background: rgba(168,85,247,0.1); color: white; } .legend-item:hover { background: rgba(168,85,247,0.1); color: white; }
.legend-item.disabled { opacity: 0.35; }
.legend-item.disabled .legend-color { box-shadow: none; }
.legend-color { width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 6px currentColor; } .legend-color { width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 6px currentColor; }
.legend-checkbox { width: 14px; height: 14px; margin-right: 8px; accent-color: #a855f7; cursor: pointer; flex-shrink: 0; }
.legend-buttons { display: flex; gap: 6px; margin-bottom: 12px; }
.legend-btn { background: rgba(168,85,247,0.15); border: 1px solid rgba(168,85,247,0.2); color: rgba(255,255,255,0.6); padding: 4px 8px; border-radius: 4px; font-size: 0.6rem; font-family: 'Space Grotesk', sans-serif; cursor: pointer; transition: all 0.2s; }
.legend-btn:hover { background: rgba(168,85,247,0.3); color: white; }
#axes-legend { bottom: 20px; right: 20px; } #axes-legend { bottom: 20px; right: 20px; }
.axis-item { display: flex; align-items: center; margin-bottom: 6px; font-size: 0.65rem; color: rgba(255,255,255,0.5); font-family: 'JetBrains Mono', monospace; } .axis-item { display: flex; align-items: center; margin-bottom: 6px; font-size: 0.65rem; color: rgba(255,255,255,0.5); font-family: 'JetBrains Mono', monospace; }
@ -84,6 +90,11 @@
<div id="legend" class="panel"> <div id="legend" class="panel">
<h3>Artists</h3> <h3>Artists</h3>
<div class="legend-buttons">
<button class="legend-btn" id="selectAll">All</button>
<button class="legend-btn" id="selectNone">None</button>
<button class="legend-btn" id="selectMain">Das Only</button>
</div>
<div id="artist-legend"></div> <div id="artist-legend"></div>
</div> </div>
@ -463,7 +474,9 @@
function createConnections() { function createConnections() {
connectionGroup.clear(); connectionGroup.clear();
for (let i = 0; i < artistGroups.length; i++) { for (let i = 0; i < artistGroups.length; i++) {
if (!artistGroups[i].visible) continue; // skip hidden artists
for (let j = i + 1; j < artistGroups.length; j++) { for (let j = i + 1; j < artistGroups.length; j++) {
if (!artistGroups[j].visible) continue; // skip hidden artists
const dist = artistGroups[i].position.distanceTo(artistGroups[j].position); const dist = artistGroups[i].position.distanceTo(artistGroups[j].position);
if (dist < 2.5) { if (dist < 2.5) {
const opacity = Math.max(0, 0.2 - dist * 0.06); const opacity = Math.max(0, 0.2 - dist * 0.06);
@ -477,15 +490,71 @@
} }
createConnections(); createConnections();
// Legend // Legend with checkboxes
const legendContainer = document.getElementById('artist-legend'); const legendContainer = document.getElementById('artist-legend');
artists.forEach(a => { const artistVisibility = new Map(); // track which artists are visible
artists.forEach((a, idx) => {
artistVisibility.set(idx, true);
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'legend-item'; item.className = 'legend-item';
item.innerHTML = `<div class="legend-color" style="background:#${a.color.toString(16).padStart(6,'0')};color:#${a.color.toString(16).padStart(6,'0')};"></div><span>${a.name}${a.isMain?' ⭐':''}</span>`; item.dataset.idx = idx;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'legend-checkbox';
checkbox.checked = true;
checkbox.dataset.idx = idx;
const colorDot = document.createElement('div');
colorDot.className = 'legend-color';
colorDot.style.background = '#' + a.color.toString(16).padStart(6,'0');
colorDot.style.color = '#' + a.color.toString(16).padStart(6,'0');
const nameSpan = document.createElement('span');
nameSpan.textContent = a.name + (a.isMain ? ' ⭐' : '');
item.appendChild(checkbox);
item.appendChild(colorDot);
item.appendChild(nameSpan);
legendContainer.appendChild(item); legendContainer.appendChild(item);
// Toggle on checkbox change
checkbox.addEventListener('change', () => {
const visible = checkbox.checked;
artistVisibility.set(idx, visible);
artistGroups[idx].visible = visible;
item.classList.toggle('disabled', !visible);
createConnections(); // update connections
});
// Click on row (not checkbox) toggles too
item.addEventListener('click', (e) => {
if (e.target === checkbox) return;
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change'));
});
}); });
// Select all/none/main buttons
function setAllVisibility(visible, mainOnly = false) {
artists.forEach((a, idx) => {
const shouldShow = mainOnly ? a.isMain : visible;
artistVisibility.set(idx, shouldShow);
artistGroups[idx].visible = shouldShow;
const checkbox = legendContainer.querySelector(`input[data-idx="${idx}"]`);
const item = legendContainer.querySelector(`.legend-item[data-idx="${idx}"]`);
if (checkbox) checkbox.checked = shouldShow;
if (item) item.classList.toggle('disabled', !shouldShow);
});
createConnections();
}
document.getElementById('selectAll').addEventListener('click', () => setAllVisibility(true));
document.getElementById('selectNone').addEventListener('click', () => setAllVisibility(false));
document.getElementById('selectMain').addEventListener('click', () => setAllVisibility(false, true));
// Raycasting // Raycasting
const raycaster = new THREE.Raycaster(); const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(); const mouse = new THREE.Vector2();

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

View File

@ -0,0 +1,75 @@
// Cloudflare Pages Function - handles waitlist submissions securely
// API key is stored as an environment secret, not in frontend code
export async function onRequestPost(context) {
const { request, env } = context;
// CORS headers
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
try {
const body = await request.json();
const { firstName, lastName, phone, email } = body;
// Validate required fields
if (!firstName || !phone) {
return new Response(JSON.stringify({ error: 'Name and phone are required' }), {
status: 400,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
}
// Call GHL API with secret key
const ghlResponse = await fetch('https://rest.gohighlevel.com/v1/contacts/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.GHL_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
firstName,
lastName: lastName || '',
phone,
email: email || undefined,
tags: ['MCP Waitlist']
})
});
if (!ghlResponse.ok) {
const errorText = await ghlResponse.text();
console.error('GHL API error:', errorText);
return new Response(JSON.stringify({ error: 'Failed to add to waitlist' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
}
const result = await ghlResponse.json();
return new Response(JSON.stringify({ success: true, contactId: result.contact?.id }), {
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
} catch (err) {
console.error('Waitlist error:', err);
return new Response(JSON.stringify({ error: 'Server error' }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
}
}
// Handle CORS preflight
export async function onRequestOptions() {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
});
}

View File

@ -67,13 +67,13 @@
</div> </div>
<div class="hidden md:flex items-center gap-8"> <div class="hidden md:flex items-center gap-8">
<a href="#features" class="text-zinc-400 hover:text-white transition">Features</a> <a href="#features" class="text-zinc-400 hover:text-white transition">Features</a>
<a href="#pricing" class="text-zinc-400 hover:text-white transition">Pricing</a> <a href="#pricing" class="text-zinc-400 hover:text-white transition">Waitlist</a>
<a href="#faq" class="text-zinc-400 hover:text-white transition">FAQ</a> <a href="#faq" class="text-zinc-400 hover:text-white transition">FAQ</a>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="#" class="text-zinc-400 hover:text-white transition">Sign In</a> <a href="#" class="text-zinc-400 hover:text-white transition">Sign In</a>
<a href="#pricing" class="px-4 py-2 bg-brand-500 hover:bg-brand-600 rounded-lg font-medium transition"> <a href="#pricing" class="px-4 py-2 bg-brand-500 hover:bg-brand-600 rounded-lg font-medium transition">
Get Started Join Waitlist
</a> </a>
</div> </div>
</div> </div>
@ -99,9 +99,9 @@
<div class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16"> <div class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16">
<a href="#pricing" class="w-full sm:w-auto px-8 py-4 bg-brand-500 hover:bg-brand-600 rounded-xl font-semibold text-lg transition transform hover:scale-105"> <a href="#pricing" class="w-full sm:w-auto px-8 py-4 bg-brand-500 hover:bg-brand-600 rounded-xl font-semibold text-lg transition transform hover:scale-105">
Start Free Trial Join the Waitlist
</a> </a>
<a href="https://github.com/yourusername/ghl-mcp" class="w-full sm:w-auto px-8 py-4 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-xl font-semibold text-lg transition flex items-center justify-center gap-2"> <a href="https://github.com/BusyBee3333/The-Complete-GHL-MCP" class="w-full sm:w-auto px-8 py-4 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-xl font-semibold text-lg transition flex items-center justify-center gap-2">
<i data-lucide="github" class="w-5 h-5"></i> <i data-lucide="github" class="w-5 h-5"></i>
View on GitHub View on GitHub
</a> </a>
@ -255,125 +255,139 @@
</div> </div>
</section> </section>
<!-- Pricing --> <!-- Waitlist -->
<section id="pricing" class="py-20 border-t border-zinc-800/50"> <section id="pricing" class="py-20 border-t border-zinc-800/50">
<div class="max-w-6xl mx-auto px-6"> <div class="max-w-2xl mx-auto px-6">
<div class="text-center mb-16"> <div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold mb-4">Simple, transparent pricing</h2> <div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-brand-500/20 text-brand-400 text-sm font-medium mb-6">
<p class="text-xl text-zinc-400">Start free. Scale as you grow.</p> <i data-lucide="sparkles" class="w-4 h-4"></i>
Coming Soon
</div>
<h2 class="text-3xl md:text-4xl font-bold mb-4">Join the Waitlist</h2>
<p class="text-xl text-zinc-400">Be the first to know when we launch. Early access + exclusive perks for waitlist members.</p>
</div> </div>
<div class="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto"> <div class="bg-zinc-900/50 rounded-2xl border border-zinc-800 p-8">
<!-- Free --> <form id="waitlist-form" class="space-y-6">
<div class="bg-zinc-900/50 rounded-2xl border border-zinc-800 p-8"> <div>
<h3 class="text-xl font-semibold mb-2">Starter</h3> <label for="name" class="block text-sm font-medium text-zinc-300 mb-2">Name <span class="text-brand-400">*</span></label>
<p class="text-zinc-400 mb-6">Try it out, no credit card</p> <input
<div class="mb-6"> type="text"
<span class="text-4xl font-bold">$0</span> id="name"
<span class="text-zinc-500">/month</span> name="name"
required
placeholder="Your full name"
class="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition"
>
</div> </div>
<ul class="space-y-3 mb-8">
<li class="flex items-center gap-2 text-zinc-300"> <div>
<i data-lucide="check" class="w-4 h-4 text-green-400"></i> <label for="phone" class="block text-sm font-medium text-zinc-300 mb-2">Phone <span class="text-brand-400">*</span></label>
1 GHL location <input
</li> type="tel"
<li class="flex items-center gap-2 text-zinc-300"> id="phone"
<i data-lucide="check" class="w-4 h-4 text-green-400"></i> name="phone"
1,000 API calls/month required
</li> placeholder="+1 (555) 000-0000"
<li class="flex items-center gap-2 text-zinc-300"> class="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition"
<i data-lucide="check" class="w-4 h-4 text-green-400"></i> >
Community support </div>
</li>
<li class="flex items-center gap-2 text-zinc-300"> <div>
<i data-lucide="check" class="w-4 h-4 text-green-400"></i> <label for="email" class="block text-sm font-medium text-zinc-300 mb-2">Email <span class="text-zinc-500">(optional)</span></label>
All 461 tools <input
</li> type="email"
</ul> id="email"
<a href="#" class="block w-full py-3 text-center bg-zinc-800 hover:bg-zinc-700 rounded-xl font-medium transition"> name="email"
Get Started Free placeholder="you@company.com"
</a> class="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition"
>
</div>
<button
type="submit"
id="submit-btn"
class="w-full py-4 bg-brand-500 hover:bg-brand-600 rounded-xl font-semibold text-lg transition transform hover:scale-[1.02] flex items-center justify-center gap-2"
>
<span>Join the Waitlist</span>
<i data-lucide="arrow-right" class="w-5 h-5"></i>
</button>
</form>
<!-- Success Message (hidden by default) -->
<div id="success-message" class="hidden text-center py-8">
<div class="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<i data-lucide="check" class="w-8 h-8 text-green-400"></i>
</div>
<h3 class="text-2xl font-bold mb-2">You're on the list!</h3>
<p class="text-zinc-400">We'll reach out as soon as we're ready for you.</p>
</div> </div>
<!-- Pro --> <!-- Error Message (hidden by default) -->
<div class="bg-gradient-to-b from-brand-500/10 to-transparent rounded-2xl border-2 border-brand-500/50 p-8 relative"> <div id="error-message" class="hidden mt-4 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-center">
<div class="absolute -top-3 left-1/2 -translate-x-1/2 px-4 py-1 bg-brand-500 rounded-full text-sm font-medium"> Something went wrong. Please try again.
Most Popular
</div>
<h3 class="text-xl font-semibold mb-2">Pro</h3>
<p class="text-zinc-400 mb-6">For growing agencies</p>
<div class="mb-6">
<span class="text-4xl font-bold">$49</span>
<span class="text-zinc-500">/month</span>
</div>
<ul class="space-y-3 mb-8">
<li class="flex items-center gap-2 text-zinc-300">
<i data-lucide="check" class="w-4 h-4 text-green-400"></i>
5 GHL locations
</li>
<li class="flex items-center gap-2 text-zinc-300">
<i data-lucide="check" class="w-4 h-4 text-green-400"></i>
25,000 API calls/month
</li>
<li class="flex items-center gap-2 text-zinc-300">
<i data-lucide="check" class="w-4 h-4 text-green-400"></i>
Priority support
</li>
<li class="flex items-center gap-2 text-zinc-300">
<i data-lucide="check" class="w-4 h-4 text-green-400"></i>
Usage dashboard
</li>
<li class="flex items-center gap-2 text-zinc-300">
<i data-lucide="check" class="w-4 h-4 text-green-400"></i>
Webhook notifications
</li>
</ul>
<a href="#" class="block w-full py-3 text-center bg-brand-500 hover:bg-brand-600 rounded-xl font-medium transition">
Start 14-Day Trial
</a>
</div> </div>
<!-- Agency --> <p class="text-zinc-500 text-sm text-center mt-6">
<div class="bg-zinc-900/50 rounded-2xl border border-zinc-800 p-8"> <i data-lucide="lock" class="w-4 h-4 inline mr-1"></i>
<h3 class="text-xl font-semibold mb-2">Agency</h3> We respect your privacy. No spam, ever.
<p class="text-zinc-400 mb-6">For serious operators</p> </p>
<div class="mb-6">
<span class="text-4xl font-bold">$149</span>
<span class="text-zinc-500">/month</span>
</div>
<ul class="space-y-3 mb-8">
<li class="flex items-center gap-2 text-zinc-300">
<i data-lucide="check" class="w-4 h-4 text-green-400"></i>
Unlimited locations
</li>
<li class="flex items-center gap-2 text-zinc-300">
<i data-lucide="check" class="w-4 h-4 text-green-400"></i>
100,000 API calls/month
</li>
<li class="flex items-center gap-2 text-zinc-300">
<i data-lucide="check" class="w-4 h-4 text-green-400"></i>
Dedicated support
</li>
<li class="flex items-center gap-2 text-zinc-300">
<i data-lucide="check" class="w-4 h-4 text-green-400"></i>
Team seats (5)
</li>
<li class="flex items-center gap-2 text-zinc-300">
<i data-lucide="check" class="w-4 h-4 text-green-400"></i>
Audit logs
</li>
<li class="flex items-center gap-2 text-zinc-300">
<i data-lucide="check" class="w-4 h-4 text-green-400"></i>
Custom integrations
</li>
</ul>
<a href="#" class="block w-full py-3 text-center bg-zinc-800 hover:bg-zinc-700 rounded-xl font-medium transition">
Contact Sales
</a>
</div>
</div> </div>
</div> </div>
</section> </section>
<script>
document.getElementById('waitlist-form').addEventListener('submit', async function(e) {
e.preventDefault();
const form = this;
const submitBtn = document.getElementById('submit-btn');
const successMsg = document.getElementById('success-message');
const errorMsg = document.getElementById('error-message');
// Get form values
const name = document.getElementById('name').value.trim();
const phone = document.getElementById('phone').value.trim();
const email = document.getElementById('email').value.trim();
// Split name into first/last
const nameParts = name.split(' ');
const firstName = nameParts[0] || '';
const lastName = nameParts.slice(1).join(' ') || '';
// Disable button and show loading
submitBtn.disabled = true;
submitBtn.innerHTML = '<span>Submitting...</span><svg class="animate-spin w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
try {
const response = await fetch('/api/waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
firstName: firstName,
lastName: lastName,
phone: phone,
email: email || undefined
})
});
if (response.ok) {
form.classList.add('hidden');
successMsg.classList.remove('hidden');
errorMsg.classList.add('hidden');
} else {
throw new Error('API error');
}
} catch (err) {
errorMsg.classList.remove('hidden');
submitBtn.disabled = false;
submitBtn.innerHTML = '<span>Join the Waitlist</span><i data-lucide="arrow-right" class="w-5 h-5"></i>';
lucide.createIcons();
}
});
</script>
<!-- Open Source --> <!-- Open Source -->
<section class="py-20 border-t border-zinc-800/50"> <section class="py-20 border-t border-zinc-800/50">
@ -393,7 +407,7 @@
The entire MCP server is open source. Run it yourself, modify it, contribute back. The entire MCP server is open source. Run it yourself, modify it, contribute back.
The hosted version just saves you the hassle. The hosted version just saves you the hassle.
</p> </p>
<a href="https://github.com/yourusername/ghl-mcp" class="inline-flex items-center gap-2 text-brand-400 hover:text-brand-300 font-medium"> <a href="https://github.com/BusyBee3333/The-Complete-GHL-MCP" class="inline-flex items-center gap-2 text-brand-400 hover:text-brand-300 font-medium">
View on GitHub View on GitHub
<i data-lucide="arrow-right" class="w-4 h-4"></i> <i data-lucide="arrow-right" class="w-4 h-4"></i>
</a> </a>
@ -495,7 +509,7 @@
</p> </p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4"> <div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<a href="#pricing" class="w-full sm:w-auto px-8 py-4 bg-brand-500 hover:bg-brand-600 rounded-xl font-semibold text-lg transition"> <a href="#pricing" class="w-full sm:w-auto px-8 py-4 bg-brand-500 hover:bg-brand-600 rounded-xl font-semibold text-lg transition">
Start Free Trial Join the Waitlist
</a> </a>
<a href="#" class="w-full sm:w-auto px-8 py-4 bg-zinc-800 hover:bg-zinc-700 rounded-xl font-semibold text-lg transition"> <a href="#" class="w-full sm:w-auto px-8 py-4 bg-zinc-800 hover:bg-zinc-700 rounded-xl font-semibold text-lg transition">
Book a Demo Book a Demo
@ -517,7 +531,7 @@
<div class="flex items-center gap-8 text-zinc-400"> <div class="flex items-center gap-8 text-zinc-400">
<a href="#" class="hover:text-white transition">Privacy</a> <a href="#" class="hover:text-white transition">Privacy</a>
<a href="#" class="hover:text-white transition">Terms</a> <a href="#" class="hover:text-white transition">Terms</a>
<a href="https://github.com/yourusername/ghl-mcp" class="hover:text-white transition">GitHub</a> <a href="https://github.com/BusyBee3333/The-Complete-GHL-MCP" class="hover:text-white transition">GitHub</a>
<a href="#" class="hover:text-white transition">Twitter</a> <a href="#" class="hover:text-white transition">Twitter</a>
</div> </div>
<p class="text-zinc-500 text-sm">© 2026 GHL Connect. All rights reserved.</p> <p class="text-zinc-500 text-sm">© 2026 GHL Connect. All rights reserved.</p>
@ -525,6 +539,60 @@
</div> </div>
</footer> </footer>
<!-- Sticky Floating CTA -->
<style>
@keyframes wiggle {
0%, 100% { transform: rotate(-2deg); }
50% { transform: rotate(2deg); }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 20px 0 rgba(14, 165, 233, 0.4), 0 0 40px 0 rgba(14, 165, 233, 0.2); }
50% { box-shadow: 0 0 30px 5px rgba(14, 165, 233, 0.6), 0 0 60px 10px rgba(14, 165, 233, 0.3); }
}
.sticky-btn {
animation: wiggle 2.5s ease-in-out infinite, glow-pulse 2s ease-in-out infinite;
}
.sticky-btn:hover {
animation: none;
}
</style>
<div id="sticky-cta" class="fixed bottom-6 right-6 z-50 opacity-0 translate-y-4 transition-all duration-300 pointer-events-none">
<a
href="#pricing"
class="sticky-btn flex items-center gap-2 px-6 py-3 bg-brand-500 hover:bg-brand-600 rounded-full font-semibold transition-all transform hover:scale-110"
>
<i data-lucide="sparkles" class="w-5 h-5"></i>
<span>Join Waitlist</span>
</a>
</div>
<script>
// Show/hide sticky CTA based on scroll position
const stickyCta = document.getElementById('sticky-cta');
const pricingSection = document.getElementById('pricing');
function updateStickyCta() {
const scrollY = window.scrollY;
const pricingTop = pricingSection.offsetTop;
const pricingBottom = pricingTop + pricingSection.offsetHeight;
const viewportBottom = scrollY + window.innerHeight;
// Show after scrolling 300px, hide when pricing section is in view
const shouldShow = scrollY > 300 && (viewportBottom < pricingTop || scrollY > pricingBottom);
if (shouldShow) {
stickyCta.classList.remove('opacity-0', 'translate-y-4', 'pointer-events-none');
stickyCta.classList.add('opacity-100', 'translate-y-0', 'pointer-events-auto');
} else {
stickyCta.classList.add('opacity-0', 'translate-y-4', 'pointer-events-none');
stickyCta.classList.remove('opacity-100', 'translate-y-0', 'pointer-events-auto');
}
}
window.addEventListener('scroll', updateStickyCta);
updateStickyCta();
</script>
<script>lucide.createIcons();</script> <script>lucide.createIcons();</script>
</body> </body>
</html> </html>

@ -0,0 +1 @@
Subproject commit 69db02d7cf9aca4fc39ae34b6f20f26617e57316

@ -0,0 +1 @@
Subproject commit c8c50cb56815901c8315b27f84046d528fe5f35e

View File

@ -0,0 +1,47 @@
# MCP Animation Framework
Generate chat UI animation frames for MCP marketing assets.
## Usage
1. Create a config in `configs/` (see `configs/example.json`)
2. Run generator: `node generate.js configs/your-config.json`
3. Frames output to `output/{config-name}/`
## Config Format
```json
{
"name": "servicetitan-dispatch",
"title": "AI Assistant",
"userPrompt": "Show me today's dispatch schedule and help me optimize routes",
"aiResponse": "Here's your optimized dispatch overview:",
"panels": [
{
"type": "list",
"icon": "ST",
"iconColor": "#f97316",
"title": "ServiceTitan",
"items": [...]
}
]
}
```
## Panel Types
- `list` — contact/item list with status badges
- `chat` — mini chat preview
- `stats` — key metrics with comparison
- `chart` — donut/progress chart
- `table` — data table
- `map` — route/location preview
## Frames Generated
1. Empty chat
2. User typing
3. First exchange
4. AI typing
5. Loading panels
6. Full loaded

View File

@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* Capture animation frames and create GIF
*/
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const htmlFile = process.argv[2] || 'output/stripe-animation.html';
const outputDir = path.join(__dirname, 'output/stripe-frames');
async function captureFrames() {
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// Load page
const filePath = path.resolve(__dirname, htmlFile);
await page.goto(`file://${filePath}`);
// Capture frames at key moments
const frames = [
{ delay: 0, name: '01' },
{ delay: 200, name: '02' },
{ delay: 400, name: '03' },
{ delay: 600, name: '04' },
{ delay: 800, name: '05' },
{ delay: 1000, name: '06' },
{ delay: 1200, name: '07' },
{ delay: 1400, name: '08' },
{ delay: 1600, name: '09' },
{ delay: 1800, name: '10' },
{ delay: 2000, name: '11' },
{ delay: 2200, name: '12' },
];
for (const frame of frames) {
await new Promise(r => setTimeout(r, frame.delay === 0 ? 0 : 200));
await page.screenshot({
path: path.join(outputDir, `frame-${frame.name}.png`),
type: 'png'
});
console.log(`Captured frame ${frame.name}`);
}
await browser.close();
// Create GIF using ffmpeg
console.log('Creating GIF...');
try {
execSync(`ffmpeg -y -framerate 5 -i ${outputDir}/frame-%02d.png -vf "scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" ${outputDir}/../stripe-animation.gif`, { stdio: 'inherit' });
console.log('GIF created: output/stripe-animation.gif');
} catch (e) {
console.error('FFmpeg failed, trying convert...', e.message);
try {
execSync(`convert -delay 20 -loop 0 ${outputDir}/frame-*.png ${outputDir}/../stripe-animation.gif`, { stdio: 'inherit' });
console.log('GIF created with ImageMagick');
} catch (e2) {
console.error('ImageMagick also failed:', e2.message);
}
}
}
captureFrames().catch(console.error);

View File

@ -0,0 +1,55 @@
#!/usr/bin/env node
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outputDir = path.join(__dirname, 'output/demo-frames');
const htmlFile = path.join(__dirname, 'output/mcp-demo.html');
fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
async function capture() {
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
const fps = 30;
const duration = 10;
const totalFrames = fps * duration;
console.log(`Capturing ${totalFrames} frames...`);
await page.goto(`file://${htmlFile}`, { waitUntil: 'networkidle0' });
await new Promise(r => setTimeout(r, 500));
for (let i = 0; i < totalFrames; i++) {
const scroll = i / (totalFrames - 1);
await page.evaluate(s => window.updateScene(s), scroll);
await new Promise(r => setTimeout(r, 20));
await page.screenshot({
path: path.join(outputDir, `frame-${String(i+1).padStart(4,'0')}.png`),
type: 'png'
});
if (i % 30 === 0) console.log(`${Math.round(scroll*100)}%`);
}
await browser.close();
console.log('Encoding...');
const mp4 = path.join(__dirname, 'output/mcp-demo.mp4');
execSync(`ffmpeg -y -framerate ${fps} -i "${outputDir}/frame-%04d.png" -c:v libx264 -pix_fmt yuv420p -crf 18 "${mp4}"`, { stdio: 'inherit' });
console.log(`${mp4}`);
}
capture().catch(console.error);

View File

@ -0,0 +1,54 @@
#!/usr/bin/env node
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outputDir = path.join(__dirname, 'output/stripe-full-frames');
fs.mkdirSync(outputDir, { recursive: true });
async function captureFrames() {
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
const filePath = path.resolve(__dirname, 'output/stripe-full-flow.html');
await page.goto(`file://${filePath}`);
// More frames for smoother animation (5 seconds total at 10fps = 50 frames)
const totalFrames = 55;
const frameInterval = 100; // 100ms between frames = 10fps
for (let i = 0; i < totalFrames; i++) {
const frameNum = String(i + 1).padStart(3, '0');
await page.screenshot({
path: path.join(outputDir, `frame-${frameNum}.png`),
type: 'png'
});
console.log(`Captured frame ${frameNum}/${totalFrames}`);
await new Promise(r => setTimeout(r, frameInterval));
}
await browser.close();
// Create GIF
console.log('Creating GIF...');
const gifPath = path.join(__dirname, 'output/stripe-full-flow.gif');
try {
execSync(`ffmpeg -y -framerate 10 -i "${outputDir}/frame-%03d.png" -vf "scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=256[p];[s1][p]paletteuse=dither=bayer" "${gifPath}"`, { stdio: 'inherit' });
console.log(`GIF created: ${gifPath}`);
} catch (e) {
console.error('FFmpeg error:', e.message);
}
}
captureFrames().catch(console.error);

View File

@ -0,0 +1,66 @@
#!/usr/bin/env node
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outputDir = path.join(__dirname, 'output/scroll-v2-frames');
const htmlFile = path.join(__dirname, 'output/stripe-scroll-v2.html');
fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
async function captureFrames() {
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// 15 seconds at 30fps = 450 frames (longer for slower pacing)
const fps = 30;
const duration = 15;
const totalFrames = fps * duration;
console.log(`Capturing ${totalFrames} frames...`);
await page.goto(`file://${htmlFile}`, { waitUntil: 'networkidle0' });
await new Promise(r => setTimeout(r, 500));
for (let i = 0; i < totalFrames; i++) {
const scrollProgress = i / (totalFrames - 1);
await page.evaluate((progress) => {
window.updateScene(progress);
}, scrollProgress);
await new Promise(r => setTimeout(r, 16));
const frameNum = String(i + 1).padStart(4, '0');
await page.screenshot({
path: path.join(outputDir, `frame-${frameNum}.png`),
type: 'png'
});
if (i % 45 === 0) console.log(`Frame ${i + 1}/${totalFrames} (${Math.round(scrollProgress * 100)}%)`);
}
await browser.close();
console.log('Creating MP4...');
const mp4Path = path.join(__dirname, 'output/stripe-scroll-v2.mp4');
try {
execSync(`ffmpeg -y -framerate ${fps} -i "${outputDir}/frame-%04d.png" -c:v libx264 -pix_fmt yuv420p -crf 18 "${mp4Path}"`, { stdio: 'inherit' });
console.log(`\n✓ MP4 created: ${mp4Path}`);
} catch (e) {
console.error('FFmpeg error:', e.message);
}
}
captureFrames().catch(console.error);

View File

@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* Scroll-driven animation capture
* Interpolates scrollProgress from 0 to 1 across frames
*/
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outputDir = path.join(__dirname, 'output/scroll-frames');
const htmlFile = path.join(__dirname, 'output/stripe-scroll.html');
// Clean and create output dir
fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
async function captureFrames() {
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// 12 seconds at 30fps = 360 frames
const fps = 30;
const duration = 12;
const totalFrames = fps * duration;
console.log(`Capturing ${totalFrames} frames...`);
// Load page and wait for fonts
await page.goto(`file://${htmlFile}`, { waitUntil: 'networkidle0' });
await new Promise(r => setTimeout(r, 500));
for (let i = 0; i < totalFrames; i++) {
// Calculate scroll progress (0 to 1)
// Add a small ease to the overall progression for smoother feel
const linearProgress = i / (totalFrames - 1);
const scrollProgress = linearProgress; // Keep linear for now, easing is in the HTML
// Update the scene
await page.evaluate((progress) => {
window.updateScene(progress);
}, scrollProgress);
// Small delay to let CSS transitions settle
await new Promise(r => setTimeout(r, 16));
const frameNum = String(i + 1).padStart(4, '0');
await page.screenshot({
path: path.join(outputDir, `frame-${frameNum}.png`),
type: 'png'
});
if (i % 30 === 0) console.log(`Frame ${i + 1}/${totalFrames} (${Math.round(linearProgress * 100)}%)`);
}
await browser.close();
// Create MP4
console.log('Creating MP4...');
const mp4Path = path.join(__dirname, 'output/stripe-scroll.mp4');
try {
execSync(`ffmpeg -y -framerate ${fps} -i "${outputDir}/frame-%04d.png" -c:v libx264 -pix_fmt yuv420p -crf 18 "${mp4Path}"`, { stdio: 'inherit' });
console.log(`\n✓ MP4 created: ${mp4Path}`);
} catch (e) {
console.error('FFmpeg error:', e.message);
}
}
captureFrames().catch(console.error);

View File

@ -0,0 +1,66 @@
#!/usr/bin/env node
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outputDir = path.join(__dirname, 'output/template-frames');
const htmlFile = path.join(__dirname, 'output/stripe-template.html');
fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
async function captureFrames() {
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// 12 seconds at 30fps = 360 frames
const fps = 30;
const duration = 12;
const totalFrames = fps * duration;
console.log(`Capturing ${totalFrames} frames...`);
await page.goto(`file://${htmlFile}`, { waitUntil: 'networkidle0' });
await new Promise(r => setTimeout(r, 500));
for (let i = 0; i < totalFrames; i++) {
const scrollProgress = i / (totalFrames - 1);
await page.evaluate((progress) => {
window.updateScene(progress);
}, scrollProgress);
await new Promise(r => setTimeout(r, 16));
const frameNum = String(i + 1).padStart(4, '0');
await page.screenshot({
path: path.join(outputDir, `frame-${frameNum}.png`),
type: 'png'
});
if (i % 36 === 0) console.log(`Frame ${i + 1}/${totalFrames} (${Math.round(scrollProgress * 100)}%)`);
}
await browser.close();
console.log('Creating MP4...');
const mp4Path = path.join(__dirname, 'output/stripe-template.mp4');
try {
execSync(`ffmpeg -y -framerate ${fps} -i "${outputDir}/frame-%04d.png" -c:v libx264 -pix_fmt yuv420p -crf 18 "${mp4Path}"`, { stdio: 'inherit' });
console.log(`\n✓ MP4 created: ${mp4Path}`);
} catch (e) {
console.error('FFmpeg error:', e.message);
}
}
captureFrames().catch(console.error);

View File

@ -0,0 +1,60 @@
#!/usr/bin/env node
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outputDir = path.join(__dirname, 'output/v4-frames');
const htmlFile = path.join(__dirname, 'output/stripe-v4.html');
// Clean and create output dir
fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
async function captureFrames() {
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.goto(`file://${htmlFile}`);
// 12 seconds at 30fps = 360 frames
const fps = 30;
const duration = 12;
const totalFrames = fps * duration;
const frameInterval = 1000 / fps;
console.log(`Capturing ${totalFrames} frames at ${fps}fps...`);
for (let i = 0; i < totalFrames; i++) {
const frameNum = String(i + 1).padStart(4, '0');
await page.screenshot({
path: path.join(outputDir, `frame-${frameNum}.png`),
type: 'png'
});
if (i % 30 === 0) console.log(`Frame ${i + 1}/${totalFrames}`);
await new Promise(r => setTimeout(r, frameInterval));
}
await browser.close();
// Create MP4
console.log('Creating MP4...');
const mp4Path = path.join(__dirname, 'output/stripe-v4.mp4');
try {
execSync(`ffmpeg -y -framerate ${fps} -i "${outputDir}/frame-%04d.png" -c:v libx264 -pix_fmt yuv420p -crf 18 "${mp4Path}"`, { stdio: 'inherit' });
console.log(`MP4 created: ${mp4Path}`);
} catch (e) {
console.error('FFmpeg error:', e.message);
}
}
captureFrames().catch(console.error);

View File

@ -0,0 +1,78 @@
#!/usr/bin/env node
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outputDir = path.join(__dirname, 'output/v5-frames');
const htmlFile = path.join(__dirname, 'output/stripe-v5.html');
// Clean and create output dir
fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
async function captureFrames() {
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// 10 seconds at 30fps = 300 frames
const fps = 30;
const duration = 10;
const totalFrames = fps * duration;
const frameInterval = 1000 / fps; // ~33.33ms per frame
console.log(`Capturing ${totalFrames} frames at ${fps}fps...`);
// Load the page fresh for each capture run
await page.goto(`file://${htmlFile}`, { waitUntil: 'domcontentloaded' });
// Let fonts load
await new Promise(r => setTimeout(r, 500));
// Reload to restart animation from 0
await page.goto(`file://${htmlFile}`, { waitUntil: 'domcontentloaded' });
const captureStart = Date.now();
for (let i = 0; i < totalFrames; i++) {
// Calculate when this frame should be captured (in real time from start)
const targetTime = i * frameInterval;
const elapsed = Date.now() - captureStart;
// Wait until we reach the target time
if (elapsed < targetTime) {
await new Promise(r => setTimeout(r, targetTime - elapsed));
}
const frameNum = String(i + 1).padStart(4, '0');
await page.screenshot({
path: path.join(outputDir, `frame-${frameNum}.png`),
type: 'png'
});
if (i % 30 === 0) console.log(`Frame ${i + 1}/${totalFrames} (${Math.round((i/totalFrames)*100)}%)`);
}
await browser.close();
// Create MP4
console.log('Creating MP4...');
const mp4Path = path.join(__dirname, 'output/stripe-v5.mp4');
try {
execSync(`ffmpeg -y -framerate ${fps} -i "${outputDir}/frame-%04d.png" -c:v libx264 -pix_fmt yuv420p -crf 18 "${mp4Path}"`, { stdio: 'inherit' });
console.log(`\nMP4 created: ${mp4Path}`);
} catch (e) {
console.error('FFmpeg error:', e.message);
}
}
captureFrames().catch(console.error);

View File

@ -0,0 +1,74 @@
#!/usr/bin/env node
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outputDir = path.join(__dirname, 'output/v6-frames');
const htmlFile = path.join(__dirname, 'output/stripe-v6.html');
// Clean and create output dir
fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
async function captureFrames() {
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
// 10 seconds at 30fps = 300 frames
const fps = 30;
const duration = 10;
const totalFrames = fps * duration;
const frameInterval = 1000 / fps;
console.log(`Capturing ${totalFrames} frames at ${fps}fps...`);
// Load page and wait for fonts
await page.goto(`file://${htmlFile}`, { waitUntil: 'networkidle0' });
await new Promise(r => setTimeout(r, 300));
// Reload to restart animation from t=0
await page.goto(`file://${htmlFile}`, { waitUntil: 'domcontentloaded' });
const captureStart = Date.now();
for (let i = 0; i < totalFrames; i++) {
const targetTime = i * frameInterval;
const elapsed = Date.now() - captureStart;
if (elapsed < targetTime) {
await new Promise(r => setTimeout(r, targetTime - elapsed));
}
const frameNum = String(i + 1).padStart(4, '0');
await page.screenshot({
path: path.join(outputDir, `frame-${frameNum}.png`),
type: 'png'
});
if (i % 30 === 0) console.log(`Frame ${i + 1}/${totalFrames} (${Math.round((i/totalFrames)*100)}%)`);
}
await browser.close();
// Create MP4
console.log('Creating MP4...');
const mp4Path = path.join(__dirname, 'output/stripe-v6.mp4');
try {
execSync(`ffmpeg -y -framerate ${fps} -i "${outputDir}/frame-%04d.png" -c:v libx264 -pix_fmt yuv420p -crf 18 "${mp4Path}"`, { stdio: 'inherit' });
console.log(`\nMP4 created: ${mp4Path}`);
} catch (e) {
console.error('FFmpeg error:', e.message);
}
}
captureFrames().catch(console.error);

View File

@ -0,0 +1,57 @@
#!/usr/bin/env node
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outputDir = path.join(__dirname, 'output/web-embed-frames');
fs.mkdirSync(outputDir, { recursive: true });
async function captureFrames() {
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720 });
const filePath = path.resolve(__dirname, 'web-embed/stripe-simulation.html');
await page.goto(`file://${filePath}`);
// Wait for animation to start
await new Promise(r => setTimeout(r, 500));
// Capture 12 seconds at 10fps = 120 frames
const totalFrames = 120;
const frameInterval = 100;
for (let i = 0; i < totalFrames; i++) {
const frameNum = String(i + 1).padStart(3, '0');
await page.screenshot({
path: path.join(outputDir, `frame-${frameNum}.png`),
type: 'png'
});
if (i % 10 === 0) console.log(`Captured frame ${frameNum}/${totalFrames}`);
await new Promise(r => setTimeout(r, frameInterval));
}
await browser.close();
// Create MP4
console.log('Creating video...');
const mp4Path = path.join(__dirname, 'output/stripe-web-simulation.mp4');
try {
execSync(`ffmpeg -y -framerate 10 -i "${outputDir}/frame-%03d.png" -c:v libx264 -pix_fmt yuv420p -crf 23 "${mp4Path}"`, { stdio: 'inherit' });
console.log(`Video created: ${mp4Path}`);
} catch (e) {
console.error('FFmpeg error:', e.message);
}
}
captureFrames().catch(console.error);

View File

@ -0,0 +1,25 @@
{
"name": "servicetitan-dispatch",
"title": "AI Assistant",
"dimensions": { "width": 1920, "height": 1080 },
"userPrompt": "Show me today's dispatch schedule and help me optimize routes",
"aiResponse": "Here's your dispatch schedule with optimized routing:",
"panel": {
"type": "list",
"icon": "ST",
"iconColor": "#f97316",
"title": "ServiceTitan",
"subtitle": "Today's Jobs • 5 scheduled",
"items": [
{ "avatar": "JT", "name": "John's HVAC - AC Repair", "status": "en-route", "statusColor": "#3b82f6", "meta": "8:30 AM • 2.1 mi" },
{ "avatar": "SM", "name": "Smith Residence - Plumbing", "status": "scheduled", "statusColor": "#8b5cf6", "meta": "10:00 AM • 4.8 mi" },
{ "avatar": "WH", "name": "Wilson Home - Furnace", "status": "scheduled", "statusColor": "#8b5cf6", "meta": "12:30 PM • 1.2 mi" },
{ "avatar": "AC", "name": "Acme Office - Maintenance", "status": "scheduled", "statusColor": "#8b5cf6", "meta": "2:00 PM • 3.5 mi" },
{ "avatar": "BR", "name": "Brown Family - Water Heater", "status": "urgent", "statusColor": "#dc2626", "meta": "ASAP • 0.8 mi" }
],
"footer": {
"left": "Total Drive: 2.4 hrs",
"right": "Est. Revenue: $2,450"
}
}
}

View File

@ -0,0 +1,452 @@
#!/usr/bin/env node
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { mcpConfigs } from './mcp-configs.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ONLY these 3 MCPs
const TARGETS = ['pipedrive', 'helpscout', 'basecamp'];
const configs = mcpConfigs.filter(c => TARGETS.includes(c.id));
console.log('Configs found:', configs.map(c => c.id));
function generateHTML(config) {
const { name, color, question, statLabel, statValue, statLabel2, statValue2, rows, insight } = config;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1920, height=1080">
<title>${name} MCP Demo</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a12;
--card: #12121c;
--border: rgba(255,255,255,0.08);
--text: #ffffff;
--text-dim: #888;
--accent: ${color};
--gradient: linear-gradient(135deg, #667eea, #764ba2);
--green: #22c55e;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg);
width: 1920px;
height: 1080px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.scene {
width: 1920px;
height: 1080px;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at 50% 0%, ${color}15 0%, transparent 60%);
}
.chat {
width: 900px;
background: var(--card);
border-radius: 20px;
border: 1px solid var(--border);
box-shadow: 0 40px 80px rgba(0,0,0,0.5);
overflow: hidden;
opacity: 0;
transform: translateY(30px);
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.chat.visible { opacity: 1; transform: translateY(0); }
.chat-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
}
.dots { display: flex; gap: 6px; }
.dot { width: 12px; height: 12px; border-radius: 50%; }
.dot.red { background: #ff5f56; }
.dot.yellow { background: #ffbd2e; }
.dot.green { background: #27ca40; }
.chat-title {
flex: 1;
text-align: center;
font-size: 13px;
color: var(--text-dim);
font-weight: 500;
}
.messages {
padding: 24px;
min-height: 500px;
display: flex;
flex-direction: column;
gap: 20px;
}
.msg {
max-width: 85%;
padding: 14px 18px;
border-radius: 16px;
font-size: 15px;
line-height: 1.5;
opacity: 0;
transform: translateY(15px);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.msg.visible { opacity: 1; transform: translateY(0); }
.msg.user {
align-self: flex-end;
background: var(--gradient);
color: var(--text);
border-bottom-right-radius: 4px;
}
.msg.ai {
align-self: flex-start;
background: rgba(255,255,255,0.05);
color: var(--text);
border-bottom-left-radius: 4px;
}
.typing {
display: flex;
gap: 4px;
padding: 14px 18px;
background: rgba(255,255,255,0.05);
border-radius: 16px;
border-bottom-left-radius: 4px;
width: fit-content;
opacity: 0;
transition: opacity 0.3s;
}
.typing.visible { opacity: 1; }
.typing span {
width: 6px;
height: 6px;
background: var(--text-dim);
border-radius: 50%;
animation: bounce 1.2s infinite;
}
.typing span:nth-child(2) { animation-delay: 0.15s; }
.typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
}
.mcp-card {
background: #fff;
border-radius: 12px;
overflow: hidden;
margin-top: 12px;
box-shadow: 0 4px 20px ${color}25;
opacity: 0;
transform: scale(0.95);
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.mcp-card.visible { opacity: 1; transform: scale(1); }
.mcp-head {
padding: 14px 16px;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
gap: 10px;
}
.mcp-logo {
background: var(--accent);
color: #fff;
font-weight: 700;
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
}
.mcp-head-text { font-size: 13px; color: #1a1a2e; font-weight: 600; }
.mcp-stats {
display: flex;
gap: 24px;
padding: 14px 16px;
background: #f8fafc;
}
.stat-value { font-size: 20px; font-weight: 700; color: #1a1a2e; }
.stat-value.green { color: var(--green); }
.stat-label { font-size: 10px; color: #64748b; text-transform: uppercase; margin-top: 2px; }
.mcp-rows { padding: 8px 0; }
.mcp-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
font-size: 13px;
color: #1a1a2e;
opacity: 0;
transform: translateX(-10px);
transition: all 0.3s ease;
}
.mcp-row.visible { opacity: 1; transform: translateX(0); }
.mcp-row-label { font-weight: 500; }
.mcp-row-value { font-weight: 600; color: #64748b; }
.insight {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 12px;
padding: 14px 16px;
margin-top: 12px;
opacity: 0;
transform: translateY(10px);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.insight.visible { opacity: 1; transform: translateY(0); }
.insight-title {
font-size: 12px;
font-weight: 600;
color: var(--green);
margin-bottom: 6px;
}
.insight-text {
font-size: 14px;
color: var(--text);
line-height: 1.5;
}
.input-area {
padding: 16px 20px;
border-top: 1px solid var(--border);
}
.input-box {
display: flex;
align-items: center;
gap: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
}
.input-box.active {
border-color: var(--accent);
box-shadow: 0 0 0 3px ${color}25;
}
.input-text {
flex: 1;
font-size: 14px;
color: var(--text);
}
.input-text .placeholder { color: var(--text-dim); }
.input-text .cursor {
display: inline-block;
width: 2px;
height: 16px;
background: var(--accent);
margin-left: 1px;
vertical-align: text-bottom;
animation: blink 0.8s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
.send-btn {
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--card);
border: none;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.send-btn.ready { background: var(--gradient); }
.send-btn svg { width: 16px; height: 16px; fill: #fff; }
</style>
</head>
<body>
<div class="scene">
<div class="chat" id="chat">
<div class="chat-header">
<div class="dots">
<span class="dot red"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
</div>
<div class="chat-title">AI Assistant · ${name} Connected</div>
<div style="width:50px"></div>
</div>
<div class="messages">
<div class="msg user" id="userMsg">${question}</div>
<div class="typing" id="typing">
<span></span><span></span><span></span>
</div>
<div class="msg ai" id="aiMsg">
Here's what I found:
<div class="mcp-card" id="mcpCard">
<div class="mcp-head">
<span class="mcp-logo">${name.substring(0, 2).toLowerCase()}</span>
<span class="mcp-head-text">${name}</span>
</div>
<div class="mcp-stats">
<div>
<div class="stat-value green">${statValue}</div>
<div class="stat-label">${statLabel}</div>
</div>
<div>
<div class="stat-value">${statValue2}</div>
<div class="stat-label">${statLabel2}</div>
</div>
</div>
<div class="mcp-rows">
<div class="mcp-row" id="row1">
<span class="mcp-row-label">${rows[0].label}</span>
<span class="mcp-row-value">${rows[0].value}</span>
</div>
<div class="mcp-row" id="row2">
<span class="mcp-row-label">${rows[1].label}</span>
<span class="mcp-row-value">${rows[1].value}</span>
</div>
<div class="mcp-row" id="row3">
<span class="mcp-row-label">${rows[2].label}</span>
<span class="mcp-row-value">${rows[2].value}</span>
</div>
</div>
</div>
<div class="insight" id="insight">
<div class="insight-title">💡 Recommendation</div>
<div class="insight-text">${insight}</div>
</div>
</div>
</div>
<div class="input-area">
<div class="input-box" id="inputBox">
<div class="input-text" id="inputText"><span class="placeholder">Ask anything...</span></div>
<button class="send-btn" id="sendBtn">
<svg viewBox="0 0 24 24"><path d="M2 21L23 12 2 3v7l15 2-15 2z"/></svg>
</button>
</div>
</div>
</div>
</div>
<script>
const question = ${JSON.stringify(question)};
function ease(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2; }
function phase(scroll, start, end) {
if (scroll < start) return 0;
if (scroll > end) return 1;
return (scroll - start) / (end - start);
}
function updateScene(scroll) {
const chat = document.getElementById('chat');
const inputBox = document.getElementById('inputBox');
const inputText = document.getElementById('inputText');
const sendBtn = document.getElementById('sendBtn');
const userMsg = document.getElementById('userMsg');
const typing = document.getElementById('typing');
const aiMsg = document.getElementById('aiMsg');
const mcpCard = document.getElementById('mcpCard');
const row1 = document.getElementById('row1');
const row2 = document.getElementById('row2');
const row3 = document.getElementById('row3');
const insight = document.getElementById('insight');
chat.classList.toggle('visible', scroll >= 0.02);
const typeProgress = phase(scroll, 0.08, 0.28);
const chars = Math.floor(ease(typeProgress) * question.length);
if (scroll < 0.08) {
inputText.innerHTML = '<span class="placeholder">Ask anything...</span>';
inputBox.classList.remove('active');
sendBtn.classList.remove('ready');
} else if (scroll < 0.30) {
inputBox.classList.add('active');
inputText.innerHTML = (chars > 0 ? question.substring(0, chars) : '') + '<span class="cursor"></span>';
sendBtn.classList.toggle('ready', chars === question.length);
} else {
inputBox.classList.remove('active');
inputText.innerHTML = '<span class="placeholder">Ask anything...</span>';
sendBtn.classList.remove('ready');
}
userMsg.classList.toggle('visible', scroll >= 0.30);
typing.classList.toggle('visible', scroll >= 0.38 && scroll < 0.48);
aiMsg.classList.toggle('visible', scroll >= 0.48);
mcpCard.classList.toggle('visible', scroll >= 0.55);
row1.classList.toggle('visible', scroll >= 0.62);
row2.classList.toggle('visible', scroll >= 0.67);
row3.classList.toggle('visible', scroll >= 0.72);
insight.classList.toggle('visible', scroll >= 0.80);
}
window.updateScene = updateScene;
updateScene(0);
</script>
</body>
</html>`;
}
async function renderVideo(config, browser) {
const htmlPath = path.join(__dirname, 'output', config.id + '.html');
const framesDir = path.join(__dirname, 'output', config.id + '-frames');
const mp4Path = path.join(__dirname, 'output', config.id + '.mp4');
fs.writeFileSync(htmlPath, generateHTML(config));
fs.rmSync(framesDir, { recursive: true, force: true });
fs.mkdirSync(framesDir, { recursive: true });
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.goto('file://' + htmlPath, { waitUntil: 'networkidle0' });
await new Promise(r => setTimeout(r, 300));
const fps = 30;
const totalFrames = 300;
for (let i = 0; i < totalFrames; i++) {
const scroll = i / (totalFrames - 1);
await page.evaluate(s => window.updateScene(s), scroll);
await new Promise(r => setTimeout(r, 15));
await page.screenshot({
path: path.join(framesDir, 'frame-' + String(i+1).padStart(4,'0') + '.png'),
type: 'png'
});
}
await page.close();
execSync('ffmpeg -y -framerate ' + fps + ' -i "' + framesDir + '/frame-%04d.png" -c:v libx264 -pix_fmt yuv420p -crf 20 "' + mp4Path + '"', { stdio: 'pipe' });
fs.rmSync(framesDir, { recursive: true, force: true });
return mp4Path;
}
async function main() {
console.log('\n🎬 Generating ' + configs.length + ' MCP videos: ' + configs.map(c => c.name).join(', ') + '\n');
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
const results = [];
for (let i = 0; i < configs.length; i++) {
const config = configs[i];
const start = Date.now();
process.stdout.write('[' + (i+1) + '/' + configs.length + '] ' + config.name + '... ');
try {
await renderVideo(config, browser);
console.log('✓ (' + Math.round((Date.now() - start) / 1000) + 's)');
results.push({ name: config.name, id: config.id, success: true });
} catch (err) {
console.log('✗ ' + err.message);
results.push({ name: config.name, id: config.id, success: false, error: err.message });
}
}
await browser.close();
console.log('\n✓ Done! Videos saved to output/\n');
console.log('Results:');
results.forEach(r => console.log(' ' + (r.success ? '✓' : '✗') + ' ' + r.name + (r.error ? ': ' + r.error : '')));
}
main().catch(console.error);

View File

@ -0,0 +1,448 @@
#!/usr/bin/env node
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { mcpConfigs } from './mcp-configs.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function generateHTML(config) {
const { name, color, question, statLabel, statValue, statLabel2, statValue2, rows, insight } = config;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1920, height=1080">
<title>${name} MCP Demo</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a12;
--card: #12121c;
--border: rgba(255,255,255,0.08);
--text: #ffffff;
--text-dim: #888;
--accent: ${color};
--gradient: linear-gradient(135deg, #667eea, #764ba2);
--green: #22c55e;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg);
width: 1920px;
height: 1080px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.scene {
width: 1920px;
height: 1080px;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at 50% 0%, ${color}15 0%, transparent 60%);
}
.chat {
width: 900px;
background: var(--card);
border-radius: 20px;
border: 1px solid var(--border);
box-shadow: 0 40px 80px rgba(0,0,0,0.5);
overflow: hidden;
opacity: 0;
transform: translateY(30px);
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.chat.visible { opacity: 1; transform: translateY(0); }
.chat-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
}
.dots { display: flex; gap: 6px; }
.dot { width: 12px; height: 12px; border-radius: 50%; }
.dot.red { background: #ff5f56; }
.dot.yellow { background: #ffbd2e; }
.dot.green { background: #27ca40; }
.chat-title {
flex: 1;
text-align: center;
font-size: 13px;
color: var(--text-dim);
font-weight: 500;
}
.messages {
padding: 24px;
min-height: 500px;
display: flex;
flex-direction: column;
gap: 20px;
}
.msg {
max-width: 85%;
padding: 14px 18px;
border-radius: 16px;
font-size: 15px;
line-height: 1.5;
opacity: 0;
transform: translateY(15px);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.msg.visible { opacity: 1; transform: translateY(0); }
.msg.user {
align-self: flex-end;
background: var(--gradient);
color: var(--text);
border-bottom-right-radius: 4px;
}
.msg.ai {
align-self: flex-start;
background: rgba(255,255,255,0.05);
color: var(--text);
border-bottom-left-radius: 4px;
}
.typing {
display: flex;
gap: 4px;
padding: 14px 18px;
background: rgba(255,255,255,0.05);
border-radius: 16px;
border-bottom-left-radius: 4px;
width: fit-content;
opacity: 0;
transition: opacity 0.3s;
}
.typing.visible { opacity: 1; }
.typing span {
width: 6px;
height: 6px;
background: var(--text-dim);
border-radius: 50%;
animation: bounce 1.2s infinite;
}
.typing span:nth-child(2) { animation-delay: 0.15s; }
.typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
}
.mcp-card {
background: #fff;
border-radius: 12px;
overflow: hidden;
margin-top: 12px;
box-shadow: 0 4px 20px ${color}25;
opacity: 0;
transform: scale(0.95);
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.mcp-card.visible { opacity: 1; transform: scale(1); }
.mcp-head {
padding: 14px 16px;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
gap: 10px;
}
.mcp-logo {
background: var(--accent);
color: #fff;
font-weight: 700;
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
}
.mcp-head-text { font-size: 13px; color: #1a1a2e; font-weight: 600; }
.mcp-stats {
display: flex;
gap: 24px;
padding: 14px 16px;
background: #f8fafc;
}
.stat-value { font-size: 20px; font-weight: 700; color: #1a1a2e; }
.stat-value.green { color: var(--green); }
.stat-label { font-size: 10px; color: #64748b; text-transform: uppercase; margin-top: 2px; }
.mcp-rows { padding: 8px 0; }
.mcp-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
font-size: 13px;
color: #1a1a2e;
opacity: 0;
transform: translateX(-10px);
transition: all 0.3s ease;
}
.mcp-row.visible { opacity: 1; transform: translateX(0); }
.mcp-row-label { font-weight: 500; }
.mcp-row-value { font-weight: 600; color: #64748b; }
.insight {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 12px;
padding: 14px 16px;
margin-top: 12px;
opacity: 0;
transform: translateY(10px);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.insight.visible { opacity: 1; transform: translateY(0); }
.insight-title {
font-size: 12px;
font-weight: 600;
color: var(--green);
margin-bottom: 6px;
}
.insight-text {
font-size: 14px;
color: var(--text);
line-height: 1.5;
}
.input-area {
padding: 16px 20px;
border-top: 1px solid var(--border);
}
.input-box {
display: flex;
align-items: center;
gap: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
}
.input-box.active {
border-color: var(--accent);
box-shadow: 0 0 0 3px ${color}25;
}
.input-text {
flex: 1;
font-size: 14px;
color: var(--text);
}
.input-text .placeholder { color: var(--text-dim); }
.input-text .cursor {
display: inline-block;
width: 2px;
height: 16px;
background: var(--accent);
margin-left: 1px;
vertical-align: text-bottom;
animation: blink 0.8s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
.send-btn {
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--card);
border: none;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.send-btn.ready { background: var(--gradient); }
.send-btn svg { width: 16px; height: 16px; fill: #fff; }
</style>
</head>
<body>
<div class="scene">
<div class="chat" id="chat">
<div class="chat-header">
<div class="dots">
<span class="dot red"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
</div>
<div class="chat-title">AI Assistant · ${name} Connected</div>
<div style="width:50px"></div>
</div>
<div class="messages">
<div class="msg user" id="userMsg">${question}</div>
<div class="typing" id="typing">
<span></span><span></span><span></span>
</div>
<div class="msg ai" id="aiMsg">
Here's what I found:
<div class="mcp-card" id="mcpCard">
<div class="mcp-head">
<span class="mcp-logo">${name.substring(0, 2).toLowerCase()}</span>
<span class="mcp-head-text">${name}</span>
</div>
<div class="mcp-stats">
<div>
<div class="stat-value green">${statValue}</div>
<div class="stat-label">${statLabel}</div>
</div>
<div>
<div class="stat-value">${statValue2}</div>
<div class="stat-label">${statLabel2}</div>
</div>
</div>
<div class="mcp-rows">
<div class="mcp-row" id="row1">
<span class="mcp-row-label">${rows[0].label}</span>
<span class="mcp-row-value">${rows[0].value}</span>
</div>
<div class="mcp-row" id="row2">
<span class="mcp-row-label">${rows[1].label}</span>
<span class="mcp-row-value">${rows[1].value}</span>
</div>
<div class="mcp-row" id="row3">
<span class="mcp-row-label">${rows[2].label}</span>
<span class="mcp-row-value">${rows[2].value}</span>
</div>
</div>
</div>
<div class="insight" id="insight">
<div class="insight-title">💡 Recommendation</div>
<div class="insight-text">${insight}</div>
</div>
</div>
</div>
<div class="input-area">
<div class="input-box" id="inputBox">
<div class="input-text" id="inputText"><span class="placeholder">Ask anything...</span></div>
<button class="send-btn" id="sendBtn">
<svg viewBox="0 0 24 24"><path d="M2 21L23 12 2 3v7l15 2-15 2z"/></svg>
</button>
</div>
</div>
</div>
</div>
<script>
const question = ${JSON.stringify(question)};
function ease(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2; }
function phase(scroll, start, end) {
if (scroll < start) return 0;
if (scroll > end) return 1;
return (scroll - start) / (end - start);
}
function updateScene(scroll) {
const chat = document.getElementById('chat');
const inputBox = document.getElementById('inputBox');
const inputText = document.getElementById('inputText');
const sendBtn = document.getElementById('sendBtn');
const userMsg = document.getElementById('userMsg');
const typing = document.getElementById('typing');
const aiMsg = document.getElementById('aiMsg');
const mcpCard = document.getElementById('mcpCard');
const row1 = document.getElementById('row1');
const row2 = document.getElementById('row2');
const row3 = document.getElementById('row3');
const insight = document.getElementById('insight');
chat.classList.toggle('visible', scroll >= 0.02);
const typeProgress = phase(scroll, 0.08, 0.28);
const chars = Math.floor(ease(typeProgress) * question.length);
if (scroll < 0.08) {
inputText.innerHTML = '<span class="placeholder">Ask anything...</span>';
inputBox.classList.remove('active');
sendBtn.classList.remove('ready');
} else if (scroll < 0.30) {
inputBox.classList.add('active');
inputText.innerHTML = (chars > 0 ? question.substring(0, chars) : '') + '<span class="cursor"></span>';
sendBtn.classList.toggle('ready', chars === question.length);
} else {
inputBox.classList.remove('active');
inputText.innerHTML = '<span class="placeholder">Ask anything...</span>';
sendBtn.classList.remove('ready');
}
userMsg.classList.toggle('visible', scroll >= 0.30);
typing.classList.toggle('visible', scroll >= 0.38 && scroll < 0.48);
aiMsg.classList.toggle('visible', scroll >= 0.48);
mcpCard.classList.toggle('visible', scroll >= 0.55);
row1.classList.toggle('visible', scroll >= 0.62);
row2.classList.toggle('visible', scroll >= 0.67);
row3.classList.toggle('visible', scroll >= 0.72);
insight.classList.toggle('visible', scroll >= 0.80);
}
window.updateScene = updateScene;
updateScene(0);
</script>
</body>
</html>`;
}
async function renderVideo(config, browser) {
const htmlPath = path.join(__dirname, 'output', `${config.id}.html`);
const framesDir = path.join(__dirname, 'output', `${config.id}-frames`);
const mp4Path = path.join(__dirname, 'output', `${config.id}.mp4`);
// Generate HTML
fs.writeFileSync(htmlPath, generateHTML(config));
// Create frames directory
fs.rmSync(framesDir, { recursive: true, force: true });
fs.mkdirSync(framesDir, { recursive: true });
// Capture frames
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.goto(`file://${htmlPath}`, { waitUntil: 'networkidle0' });
await new Promise(r => setTimeout(r, 300));
const fps = 30;
const totalFrames = 300; // 10 seconds
for (let i = 0; i < totalFrames; i++) {
const scroll = i / (totalFrames - 1);
await page.evaluate(s => window.updateScene(s), scroll);
await new Promise(r => setTimeout(r, 15));
await page.screenshot({
path: path.join(framesDir, `frame-${String(i+1).padStart(4,'0')}.png`),
type: 'png'
});
}
await page.close();
// Encode video
execSync(`ffmpeg -y -framerate ${fps} -i "${framesDir}/frame-%04d.png" -c:v libx264 -pix_fmt yuv420p -crf 20 "${mp4Path}"`, { stdio: 'pipe' });
// Cleanup frames
fs.rmSync(framesDir, { recursive: true, force: true });
return mp4Path;
}
async function main() {
console.log(`\n🎬 Generating ${mcpConfigs.length} MCP videos...\n`);
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
for (let i = 0; i < mcpConfigs.length; i++) {
const config = mcpConfigs[i];
const start = Date.now();
process.stdout.write(`[${i+1}/${mcpConfigs.length}] ${config.name}... `);
try {
await renderVideo(config, browser);
console.log(`✓ (${Math.round((Date.now() - start) / 1000)}s)`);
} catch (err) {
console.log(`${err.message}`);
}
}
await browser.close();
console.log(`\n✓ Done! Videos saved to output/\n`);
}
main().catch(console.error);

View File

@ -0,0 +1,458 @@
#!/usr/bin/env node
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import { mcpConfigs } from './mcp-configs.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Only render these 3 MCPs
const targetIds = ['lightspeed', 'bigcommerce', 'toast'];
const configs = mcpConfigs.filter(c => targetIds.includes(c.id));
function generateHTML(config) {
const { name, color, question, statLabel, statValue, statLabel2, statValue2, rows, insight } = config;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1920, height=1080">
<title>${name} MCP Demo</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a12;
--card: #12121c;
--border: rgba(255,255,255,0.08);
--text: #ffffff;
--text-dim: #888;
--accent: ${color};
--gradient: linear-gradient(135deg, #667eea, #764ba2);
--green: #22c55e;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg);
width: 1920px;
height: 1080px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.scene {
width: 1920px;
height: 1080px;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at 50% 0%, ${color}15 0%, transparent 60%);
}
.chat {
width: 900px;
background: var(--card);
border-radius: 20px;
border: 1px solid var(--border);
box-shadow: 0 40px 80px rgba(0,0,0,0.5);
overflow: hidden;
opacity: 0;
transform: translateY(30px);
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.chat.visible { opacity: 1; transform: translateY(0); }
.chat-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
}
.dots { display: flex; gap: 6px; }
.dot { width: 12px; height: 12px; border-radius: 50%; }
.dot.red { background: #ff5f56; }
.dot.yellow { background: #ffbd2e; }
.dot.green { background: #27ca40; }
.chat-title {
flex: 1;
text-align: center;
font-size: 13px;
color: var(--text-dim);
font-weight: 500;
}
.messages {
padding: 24px;
min-height: 500px;
display: flex;
flex-direction: column;
gap: 20px;
}
.msg {
max-width: 85%;
padding: 14px 18px;
border-radius: 16px;
font-size: 15px;
line-height: 1.5;
opacity: 0;
transform: translateY(15px);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.msg.visible { opacity: 1; transform: translateY(0); }
.msg.user {
align-self: flex-end;
background: var(--gradient);
color: var(--text);
border-bottom-right-radius: 4px;
}
.msg.ai {
align-self: flex-start;
background: rgba(255,255,255,0.05);
color: var(--text);
border-bottom-left-radius: 4px;
}
.typing {
display: flex;
gap: 4px;
padding: 14px 18px;
background: rgba(255,255,255,0.05);
border-radius: 16px;
border-bottom-left-radius: 4px;
width: fit-content;
opacity: 0;
transition: opacity 0.3s;
}
.typing.visible { opacity: 1; }
.typing span {
width: 6px;
height: 6px;
background: var(--text-dim);
border-radius: 50%;
animation: bounce 1.2s infinite;
}
.typing span:nth-child(2) { animation-delay: 0.15s; }
.typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
}
.mcp-card {
background: #fff;
border-radius: 12px;
overflow: hidden;
margin-top: 12px;
box-shadow: 0 4px 20px ${color}25;
opacity: 0;
transform: scale(0.95);
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.mcp-card.visible { opacity: 1; transform: scale(1); }
.mcp-head {
padding: 14px 16px;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
gap: 10px;
}
.mcp-logo {
background: var(--accent);
color: #fff;
font-weight: 700;
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
}
.mcp-head-text { font-size: 13px; color: #1a1a2e; font-weight: 600; }
.mcp-stats {
display: flex;
gap: 24px;
padding: 14px 16px;
background: #f8fafc;
}
.stat-value { font-size: 20px; font-weight: 700; color: #1a1a2e; }
.stat-value.green { color: var(--green); }
.stat-label { font-size: 10px; color: #64748b; text-transform: uppercase; margin-top: 2px; }
.mcp-rows { padding: 8px 0; }
.mcp-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
font-size: 13px;
color: #1a1a2e;
opacity: 0;
transform: translateX(-10px);
transition: all 0.3s ease;
}
.mcp-row.visible { opacity: 1; transform: translateX(0); }
.mcp-row-label { font-weight: 500; }
.mcp-row-value { font-weight: 600; color: #64748b; }
.insight {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 12px;
padding: 14px 16px;
margin-top: 12px;
opacity: 0;
transform: translateY(10px);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.insight.visible { opacity: 1; transform: translateY(0); }
.insight-title {
font-size: 12px;
font-weight: 600;
color: var(--green);
margin-bottom: 6px;
}
.insight-text {
font-size: 14px;
color: var(--text);
line-height: 1.5;
}
.input-area {
padding: 16px 20px;
border-top: 1px solid var(--border);
}
.input-box {
display: flex;
align-items: center;
gap: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
}
.input-box.active {
border-color: var(--accent);
box-shadow: 0 0 0 3px ${color}25;
}
.input-text {
flex: 1;
font-size: 14px;
color: var(--text);
}
.input-text .placeholder { color: var(--text-dim); }
.input-text .cursor {
display: inline-block;
width: 2px;
height: 16px;
background: var(--accent);
margin-left: 1px;
vertical-align: text-bottom;
animation: blink 0.8s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
.send-btn {
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--card);
border: none;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.send-btn.ready { background: var(--gradient); }
.send-btn svg { width: 16px; height: 16px; fill: #fff; }
</style>
</head>
<body>
<div class="scene">
<div class="chat" id="chat">
<div class="chat-header">
<div class="dots">
<span class="dot red"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
</div>
<div class="chat-title">AI Assistant · ${name} Connected</div>
<div style="width:50px"></div>
</div>
<div class="messages">
<div class="msg user" id="userMsg">${question}</div>
<div class="typing" id="typing">
<span></span><span></span><span></span>
</div>
<div class="msg ai" id="aiMsg">
Here's what I found:
<div class="mcp-card" id="mcpCard">
<div class="mcp-head">
<span class="mcp-logo">${name.substring(0, 2).toLowerCase()}</span>
<span class="mcp-head-text">${name}</span>
</div>
<div class="mcp-stats">
<div>
<div class="stat-value green">${statValue}</div>
<div class="stat-label">${statLabel}</div>
</div>
<div>
<div class="stat-value">${statValue2}</div>
<div class="stat-label">${statLabel2}</div>
</div>
</div>
<div class="mcp-rows">
<div class="mcp-row" id="row1">
<span class="mcp-row-label">${rows[0].label}</span>
<span class="mcp-row-value">${rows[0].value}</span>
</div>
<div class="mcp-row" id="row2">
<span class="mcp-row-label">${rows[1].label}</span>
<span class="mcp-row-value">${rows[1].value}</span>
</div>
<div class="mcp-row" id="row3">
<span class="mcp-row-label">${rows[2].label}</span>
<span class="mcp-row-value">${rows[2].value}</span>
</div>
</div>
</div>
<div class="insight" id="insight">
<div class="insight-title">💡 Recommendation</div>
<div class="insight-text">${insight}</div>
</div>
</div>
</div>
<div class="input-area">
<div class="input-box" id="inputBox">
<div class="input-text" id="inputText"><span class="placeholder">Ask anything...</span></div>
<button class="send-btn" id="sendBtn">
<svg viewBox="0 0 24 24"><path d="M2 21L23 12 2 3v7l15 2-15 2z"/></svg>
</button>
</div>
</div>
</div>
</div>
<script>
const question = ${JSON.stringify(question)};
function ease(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2; }
function phase(scroll, start, end) {
if (scroll < start) return 0;
if (scroll > end) return 1;
return (scroll - start) / (end - start);
}
function updateScene(scroll) {
const chat = document.getElementById('chat');
const inputBox = document.getElementById('inputBox');
const inputText = document.getElementById('inputText');
const sendBtn = document.getElementById('sendBtn');
const userMsg = document.getElementById('userMsg');
const typing = document.getElementById('typing');
const aiMsg = document.getElementById('aiMsg');
const mcpCard = document.getElementById('mcpCard');
const row1 = document.getElementById('row1');
const row2 = document.getElementById('row2');
const row3 = document.getElementById('row3');
const insight = document.getElementById('insight');
chat.classList.toggle('visible', scroll >= 0.02);
const typeProgress = phase(scroll, 0.08, 0.28);
const chars = Math.floor(ease(typeProgress) * question.length);
if (scroll < 0.08) {
inputText.innerHTML = '<span class="placeholder">Ask anything...</span>';
inputBox.classList.remove('active');
sendBtn.classList.remove('ready');
} else if (scroll < 0.30) {
inputBox.classList.add('active');
inputText.innerHTML = (chars > 0 ? question.substring(0, chars) : '') + '<span class="cursor"></span>';
sendBtn.classList.toggle('ready', chars === question.length);
} else {
inputBox.classList.remove('active');
inputText.innerHTML = '<span class="placeholder">Ask anything...</span>';
sendBtn.classList.remove('ready');
}
userMsg.classList.toggle('visible', scroll >= 0.30);
typing.classList.toggle('visible', scroll >= 0.38 && scroll < 0.48);
aiMsg.classList.toggle('visible', scroll >= 0.48);
mcpCard.classList.toggle('visible', scroll >= 0.55);
row1.classList.toggle('visible', scroll >= 0.62);
row2.classList.toggle('visible', scroll >= 0.67);
row3.classList.toggle('visible', scroll >= 0.72);
insight.classList.toggle('visible', scroll >= 0.80);
}
window.updateScene = updateScene;
updateScene(0);
</script>
</body>
</html>`;
}
async function renderVideo(config, browser) {
const htmlPath = path.join(__dirname, 'output', `${config.id}.html`);
const framesDir = path.join(__dirname, 'output', `${config.id}-frames`);
const mp4Path = path.join(__dirname, 'output', `${config.id}.mp4`);
// Generate HTML
fs.writeFileSync(htmlPath, generateHTML(config));
// Create frames directory
fs.rmSync(framesDir, { recursive: true, force: true });
fs.mkdirSync(framesDir, { recursive: true });
// Capture frames
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.goto(`file://${htmlPath}`, { waitUntil: 'networkidle0' });
await new Promise(r => setTimeout(r, 300));
const fps = 30;
const totalFrames = 300; // 10 seconds
for (let i = 0; i < totalFrames; i++) {
const scroll = i / (totalFrames - 1);
await page.evaluate(s => window.updateScene(s), scroll);
await new Promise(r => setTimeout(r, 15));
await page.screenshot({
path: path.join(framesDir, `frame-${String(i+1).padStart(4,'0')}.png`),
type: 'png'
});
}
await page.close();
// Encode video
execSync(`ffmpeg -y -framerate ${fps} -i "${framesDir}/frame-%04d.png" -c:v libx264 -pix_fmt yuv420p -crf 20 "${mp4Path}"`, { stdio: 'pipe' });
// Cleanup frames
fs.rmSync(framesDir, { recursive: true, force: true });
return mp4Path;
}
async function main() {
console.log(`\n🎬 Generating ${configs.length} MCP videos...\n`);
const browser = await puppeteer.launch({
headless: true,
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
args: ['--no-sandbox']
});
const results = [];
for (let i = 0; i < configs.length; i++) {
const config = configs[i];
const start = Date.now();
process.stdout.write(`[${i+1}/${configs.length}] ${config.name}... `);
try {
await renderVideo(config, browser);
console.log(`✓ (${Math.round((Date.now() - start) / 1000)}s)`);
results.push({ name: config.name, id: config.id, success: true });
} catch (err) {
console.log(`${err.message}`);
results.push({ name: config.name, id: config.id, success: false, error: err.message });
}
}
await browser.close();
console.log(`\n✓ Done! Videos saved to output/\n`);
return results;
}
main().catch(console.error);

View File

@ -0,0 +1,393 @@
#!/usr/bin/env node
/**
* MCP Animation Frame Generator - Single Panel Mode
* Usage: node generate-single.js configs/your-config.json
*/
import fs from 'fs';
import path from 'path';
const configPath = process.argv[2];
if (!configPath) {
console.error('Usage: node generate-single.js configs/your-config.json');
process.exit(1);
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const outputDir = path.join('output', config.name);
fs.mkdirSync(outputDir, { recursive: true });
function generateListItems(items) {
return items.map(item => `
<div class="list-item">
<div class="item-avatar">${item.avatar}</div>
<div class="item-info">
<div class="item-name">${item.name}</div>
<div class="item-meta">${item.meta}</div>
</div>
<div class="item-status" style="background: ${item.statusColor};">${item.status}</div>
</div>
`).join('');
}
function generatePanel(panel) {
let content = '';
if (panel.type === 'list') {
content = `
<div class="panel-list">
${generateListItems(panel.items)}
</div>
${panel.footer ? `
<div class="panel-footer">
<span>${panel.footer.left}</span>
<span>${panel.footer.right}</span>
</div>
` : ''}
`;
}
return `
<div class="embed-panel">
<div class="panel-header">
<div class="panel-icon" style="background: ${panel.iconColor};">${panel.icon}</div>
<div class="panel-titles">
<div class="panel-title">${panel.title}</div>
${panel.subtitle ? `<div class="panel-subtitle">${panel.subtitle}</div>` : ''}
</div>
</div>
<div class="panel-content">
${content}
</div>
</div>
`;
}
function generateHTML(config, frameType) {
const showUserMessage = ['exchange', 'typing-ai', 'loading', 'final'].includes(frameType);
const showTypingUser = frameType === 'typing-user';
const showTypingAi = frameType === 'typing-ai';
const showAiText = ['loading', 'final'].includes(frameType);
const showPanel = frameType === 'final';
const showLoading = frameType === 'loading';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=${config.dimensions.width}, height=${config.dimensions.height}">
<title>${config.title}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #0f0f1a;
width: ${config.dimensions.width}px;
height: ${config.dimensions.height}px;
display: flex;
align-items: center;
justify-content: center;
}
.window {
width: 1400px;
height: 850px;
background: #1a1a2e;
border-radius: 16px;
box-shadow: 0 25px 80px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
border: 1px solid #2a2a4a;
overflow: hidden;
}
.titlebar {
height: 52px;
background: #252540;
display: flex;
align-items: center;
padding: 0 20px;
border-bottom: 1px solid #2a2a4a;
flex-shrink: 0;
}
.traffic-lights { display: flex; gap: 8px; }
.light { width: 12px; height: 12px; border-radius: 50%; }
.light.red { background: #ff5f57; }
.light.yellow { background: #ffbd2e; }
.light.green { background: #28ca41; }
.title { flex: 1; text-align: center; color: #8888aa; font-size: 14px; font-weight: 500; }
.chat-area {
flex: 1;
padding: 24px 32px;
display: flex;
flex-direction: column;
gap: 16px;
overflow: hidden;
}
.message { display: flex; flex-direction: column; }
.message.user { align-self: flex-end; max-width: 70%; }
.message.ai { align-self: flex-start; max-width: 85%; }
.bubble {
padding: 16px 20px;
border-radius: 20px;
font-size: 15px;
line-height: 1.5;
}
.message.user .bubble {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
border-bottom-right-radius: 6px;
}
.message.ai .bubble.text {
background: #2a2a4a;
color: #e2e2f0;
border-bottom-left-radius: 6px;
}
.typing-indicator {
display: flex;
gap: 5px;
padding: 16px 20px;
background: #2a2a4a;
border-radius: 20px;
width: fit-content;
}
.typing-dot {
width: 8px;
height: 8px;
background: #6366f1;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-8px); }
}
.embed-panel {
margin-top: 12px;
background: #12121f;
border-radius: 16px;
border: 1px solid #2a2a4a;
overflow: hidden;
max-width: 600px;
}
.panel-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: #1a1a2e;
border-bottom: 1px solid #2a2a4a;
}
.panel-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
color: white;
}
.panel-titles { flex: 1; }
.panel-title { font-size: 15px; font-weight: 600; color: #e2e2f0; }
.panel-subtitle { font-size: 12px; color: #8888aa; margin-top: 2px; }
.panel-content { padding: 12px; }
.panel-list { display: flex; flex-direction: column; gap: 8px; }
.list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: #1a1a2e;
border-radius: 10px;
}
.item-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #3a3a5a;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: #8888aa;
flex-shrink: 0;
}
.item-info { flex: 1; min-width: 0; }
.item-name {
color: #e2e2f0;
font-weight: 500;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta { color: #6a6a8a; font-size: 12px; margin-top: 2px; }
.item-status {
padding: 5px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
color: white;
text-transform: uppercase;
flex-shrink: 0;
}
.panel-footer {
display: flex;
justify-content: space-between;
padding: 12px 16px;
background: #1a1a2e;
border-top: 1px solid #2a2a4a;
font-size: 13px;
color: #8888aa;
}
.panel-footer span:last-child { color: #22c55e; font-weight: 600; }
.skeleton {
background: linear-gradient(90deg, #2a2a4a 25%, #3a3a5a 50%, #2a2a4a 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8px;
height: 60px;
margin: 8px 0;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.input-area {
padding: 16px 24px 24px;
border-top: 1px solid #2a2a4a;
flex-shrink: 0;
}
.input-wrapper {
display: flex;
gap: 12px;
align-items: center;
background: #12121f;
border: 1px solid #2a2a4a;
border-radius: 14px;
padding: 12px 16px;
}
.input-field { flex: 1; color: #5a5a7a; font-size: 14px; }
.send-btn {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.send-btn svg { width: 18px; height: 18px; color: white; }
</style>
</head>
<body>
<div class="window">
<div class="titlebar">
<div class="traffic-lights">
<div class="light red"></div>
<div class="light yellow"></div>
<div class="light green"></div>
</div>
<div class="title">${config.title}</div>
<div style="width: 52px;"></div>
</div>
<div class="chat-area">
${showTypingUser ? `
<div class="message user">
<div class="typing-indicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
` : ''}
${showUserMessage ? `
<div class="message user">
<div class="bubble">${config.userPrompt}</div>
</div>
` : ''}
${showTypingAi ? `
<div class="message ai">
<div class="typing-indicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
` : ''}
${showAiText ? `
<div class="message ai">
<div class="bubble text">${config.aiResponse}</div>
${showLoading ? `
<div class="embed-panel">
<div class="panel-header">
<div class="panel-icon" style="background: ${config.panel.iconColor};">${config.panel.icon}</div>
<div class="panel-titles">
<div class="panel-title">${config.panel.title}</div>
</div>
</div>
<div class="panel-content">
<div class="skeleton"></div>
<div class="skeleton"></div>
<div class="skeleton"></div>
</div>
</div>
` : ''}
${showPanel ? generatePanel(config.panel) : ''}
</div>
` : ''}
</div>
<div class="input-area">
<div class="input-wrapper">
<span class="input-field">Type a message...</span>
<div class="send-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 2L11 13M22 2L15 22L11 13L2 9L22 2Z"/>
</svg>
</div>
</div>
</div>
</div>
</body>
</html>`;
}
// Generate frames
const frames = [
{ name: 'frame-01-empty', type: 'empty' },
{ name: 'frame-02-typing-user', type: 'typing-user' },
{ name: 'frame-03-exchange', type: 'exchange' },
{ name: 'frame-04-typing-ai', type: 'typing-ai' },
{ name: 'frame-05-loading', type: 'loading' },
{ name: 'frame-06-final', type: 'final' }
];
frames.forEach(frame => {
const html = generateHTML(config, frame.type);
const filePath = path.join(outputDir, `${frame.name}.html`);
fs.writeFileSync(filePath, html);
console.log(`Generated: ${filePath}`);
});
console.log(`\n✓ Generated ${frames.length} frames in ${outputDir}/`);
console.log(`\nTo preview: open ${outputDir}/frame-06-final.html`);

Some files were not shown because too many files have changed in this diff Show More