Compare commits

..

2 Commits

Author SHA1 Message Date
Thariq Shihipar
7985b28c03 Add thinkback plugin for Year in Review animation
Adds the thinkback plugin - a personalized Year in Review ASCII animation
generator for Claude Code users.

Features:
- Multiple vibes: cozy, awards show, morning news, RPG quest
- Quick generation with templates or deep dive with personalized narratives
- Comprehensive animation helpers for backgrounds, transitions, particles
- Stats extraction from Claude Code usage history

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 19:27:57 -08:00
Noah Zweben
19a119f97e Update plugins library to include authors (#6)
* added Anthropic as author

* update figma
2025-12-12 16:52:17 -08:00
34 changed files with 12853 additions and 0 deletions

View File

@@ -139,6 +139,17 @@
"category": "development", "category": "development",
"homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/plugin-dev" "homepage": "https://github.com/anthropics/claude-plugins-public/tree/main/plugins/plugin-dev"
}, },
{
"name": "thinkback",
"description": "Generate a personalized Year in Review ASCII animation celebrating your year with Claude Code. Features multiple vibes (cozy, awards show, morning news, RPG quest) and comprehensive animation helpers.",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
},
"source": "./plugins/thinkback",
"category": "productivity",
"homepage": "https://github.com/anthropics/claude-plugins-official/tree/main/plugins/thinkback"
},
{ {
"name": "greptile", "name": "greptile",
"description": "AI-powered codebase search and understanding. Query your repositories using natural language to find relevant code, understand dependencies, and get contextual answers about your codebase architecture.", "description": "AI-powered codebase search and understanding. Query your repositories using natural language to find relevant code, understand dependencies, and get contextual answers about your codebase architecture.",

View File

@@ -0,0 +1,8 @@
{
"name": "thinkback",
"description": "Generate a personalized Year in Review ASCII animation celebrating your year with Claude Code",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
}
}

View File

@@ -0,0 +1,35 @@
# Thinkback Plugin
Generate a personalized "Year in Review" ASCII animation celebrating your year with Claude Code.
## What It Does
Creates a custom ASCII art animation showcasing your coding statistics and achievements with Claude Code. Features:
- **Multiple vibes**: Cozy fireplace, Awards show, Morning news, RPG quest
- **Personalized stats**: Commits, conversations, projects, and more
- **Animation helpers**: Backgrounds, transitions, particles, text effects
- **Quick or deep generation**: Template-based or fully personalized narratives
## Usage
```
/thinkback
```
Claude will guide you through:
1. Extracting your Claude Code usage statistics
2. Choosing a vibe for your animation
3. Selecting which projects to highlight
4. Generating your personalized Year in Review animation
## Modes
- `mode=generate` (default) - Create a new Year in Review
- `mode=edit` - Modify an existing animation
- `mode=fix` - Validate and fix errors in existing animation
- `mode=regenerate` - Start fresh
## Authors
Thariq Shihipar (thariq@anthropic.com)

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -0,0 +1,159 @@
---
name: thinkback
description: Generate a personalized "Year in Review" ASCII animation script. Use when the user wants to create their Thinkback, year in review, or usage summary animation.
---
# Thinkback - Year in Review Generator
Generate a personalized ASCII art animation celebrating the user's year with Claude Code.
## Step 1: Determine Mode
Check if the user's request includes a mode parameter:
| Mode | Action |
|------|--------|
| `mode=generate` (default) | Continue to Step 2 |
| `mode=edit` | Read existing `./year_in_review.js`, ask what to change, make edits, then validate |
| `mode=fix` | Read existing `./year_in_review.js`, run validation, fix errors until it passes |
| `mode=regenerate` | Delete existing file, continue to Step 2 |
## Step 2: Extract Statistics
Run the stats script from the skill folder root:
```bash
cd ${CLAUDE_PLUGIN_ROOT}/skills/thinkback && node scripts/get_all_stats.js --markdown
```
The `--markdown` flag also generates `activity-report.md` with:
- Every repo with Claude co-authored commits
- Recent commits per repo (up to 10)
- Recent user messages per project (up to 5)
## Step 3: Read the Activity Report
Read `activity-report.md` to understand the user's reposistories and what they're working on.
## Step 4: Interview the User
Use the AskUserQuestion tool to ask these questions.
```json
{
"questions": [
{
"question": "How would you like to generate your Thinkback?",
"header": "Generation",
"options": [
{
"label": "Quick Generation (Recommended for Pro Users)",
"description": "Uses pre-built templates with your stats injected. Faster, uses fewer tokens."
},
{
"label": "Deep Ddive",
"description": "Analyzes your projects, commits, and conversations to create a personalized narrative. Uses more tokens, takes longer."
}
],
"multiSelect": false
},
{
"question": "What vibe should your Thinkback have?",
"header": "Vibe",
"options": [
{
"label": "Cozy",
"description": "Warm and gentle, like a fireplace evening"
},
{
"label": "Awards show",
"description": "Glamorous ceremony with envelope reveals"
},
{
"label": "Morning news",
"description": "Upbeat broadcast with breaking news energy"
},
{
"label": "RPG Quest",
"description": "Epic adventure with quests and level ups"
}
],
"multiSelect": false
},
{
"question": "Which projects should we include from your Thinkback? Consider if you want to share this more publicly.",
"header": "Include - pt 1",
"options": [
{
"label": "Project 1",
"description": "Project Description"
},
{
"label": "Project 2",
"description": "Project Description"
},
{
"label": "Project 3",
"description": "Project Description"
},
{
"label": "Project 4",
"description": "Project Description"
},
],
],
"multiSelect": true
},
{
"question": "Which projects should we include from your Thinkback? Consider if you want to share this more publicly.",
"header": "Include - pt 2",
"options": [
{
"label": "Project 5",
"description": "Project Description"
},
{
"label": "Project 6",
"description": "Project Description"
},
],
"multiSelect": true
}
]
}
```
Question #1 and Question #2 are the same for every user, use this wording EXACTLY.
For Question #3, select potential projects OR repos that you want to highlight but might be sensitive.
If you have more than 4 options, you can use multiple questions (e.g. have a question 4 and 5).
## Step 5: Load the Appropriate Instructions
Based on Question 2 response:
### If "Deep dive" selected:
Read and follow instructions in: `high_token_version.md`
This mode:
- Extracts detailed stats
- Reads activity reports
- Spins off subagents to analyze repos and transcripts
- Creates a deeply personalized narrative
### If "Quick generation" selected:
Read and follow instructions in: `low_token_version.md`
This mode:
- Extracts stats
- Uses pre-built templates based on vibe selection
- Injects stats into template
- Fast and token-efficient
## Vibe Reference Files
Load the appropriate vibe guide based on Question 1:
- `vibes/cozy-vibe.md` - Warm, nurturing, unhurried aesthetic
- `vibes/awards-show-vibe.md` - Glamorous ceremony, envelope reveals
- `vibes/morning-news-vibe.md` - Cheerful broadcast, breaking news
- `vibes/rpg-quest-vibe.md` - Epic adventure, quests, level ups
- `vibes/other-vibe.md` - If they enter free text input

View File

@@ -0,0 +1,435 @@
/* eslint-disable */
/**
* ASCII Art Animation Library
* A library for creating ASCII art animations with primitives and framebuffer rendering
*/
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
class FrameBuffer {
// Density characters from light to dark
static DENSITY_CHARS = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
// Large text font (3x5 characters, accounting for aspect ratio)
static FIGLET_FONT = {
'A': [' # ', ' # # ', '#####', '# #', '# #'],
'B': ['#### ', '# #', '#### ', '# #', '#### '],
'C': [' ### ', '# #', '# ', '# #', ' ### '],
'D': ['#### ', '# #', '# #', '# #', '#### '],
'E': ['#####', '# ', '#### ', '# ', '#####'],
'F': ['#####', '# ', '#### ', '# ', '# '],
'G': [' ### ', '# ', '# ##', '# #', ' ### '],
'H': ['# #', '# #', '#####', '# #', '# #'],
'I': ['#####', ' # ', ' # ', ' # ', '#####'],
'J': ['#####', ' #', ' #', '# #', ' ### '],
'K': ['# #', '# # ', '### ', '# # ', '# #'],
'L': ['# ', '# ', '# ', '# ', '#####'],
'M': ['# #', '## ##', '# # #', '# #', '# #'],
'N': ['# #', '## #', '# # #', '# ##', '# #'],
'O': [' ### ', '# #', '# #', '# #', ' ### '],
'P': ['#### ', '# #', '#### ', '# ', '# '],
'Q': [' ### ', '# #', '# #', '# ##', ' ####'],
'R': ['#### ', '# #', '#### ', '# # ', '# #'],
'S': [' ####', '# ', ' ### ', ' #', '#### '],
'T': ['#####', ' # ', ' # ', ' # ', ' # '],
'U': ['# #', '# #', '# #', '# #', ' ### '],
'V': ['# #', '# #', '# #', ' # # ', ' # '],
'W': ['# #', '# #', '# # #', '## ##', '# #'],
'X': ['# #', ' # # ', ' # ', ' # # ', '# #'],
'Y': ['# #', ' # # ', ' # ', ' # ', ' # '],
'Z': ['#####', ' # ', ' # ', ' # ', '#####'],
'0': [' ### ', '# #', '# #', '# #', ' ### '],
'1': [' # ', ' ## ', ' # ', ' # ', '#####'],
'2': [' ### ', '# #', ' # ', ' # ', '#####'],
'3': [' ### ', '# #', ' ## ', '# #', ' ### '],
'4': ['# #', '# #', '#####', ' #', ' #'],
'5': ['#####', '# ', '#### ', ' #', '#### '],
'6': [' ### ', '# ', '#### ', '# #', ' ### '],
'7': ['#####', ' #', ' # ', ' # ', ' # '],
'8': [' ### ', '# #', ' ### ', '# #', ' ### '],
'9': [' ### ', '# #', ' ####', ' #', ' ### '],
' ': [' ', ' ', ' ', ' ', ' '],
'!': [' # ', ' # ', ' # ', ' ', ' # '],
'?': [' ### ', '# #', ' # ', ' ', ' # '],
'.': [' ', ' ', ' ', ' ', ' # '],
',': [' ', ' ', ' ', ' # ', ' # '],
':': [' ', ' # ', ' ', ' # ', ' '],
"'": [' # ', ' # ', ' ', ' ', ' '],
'-': [' ', ' ', '#####', ' ', ' '],
'+': [' ', ' # ', '#####', ' # ', ' '],
'=': [' ', '#####', ' ', '#####', ' '],
'*': [' ', '# # #', ' ### ', '# # #', ' '],
'/': [' #', ' # ', ' # ', ' # ', '# '],
'(': [' ## ', ' # ', ' # ', ' # ', ' ## '],
')': ['## ', ' # ', ' # ', ' # ', '## '],
'<': [' # ', ' # ', ' # ', ' # ', ' # '],
'>': [' # ', ' # ', ' # ', ' # ', ' # '],
};
constructor(width, height) {
this.width = width;
this.height = height;
this.clear();
}
clear(char = ' ') {
this.buffer = Array(this.height).fill(null).map(() => Array(this.width).fill(char));
this.colorBuffer = Array(this.height).fill(null).map(() => Array(this.width).fill(null));
this.depthBuffer = Array(this.height).fill(null).map(() => Array(this.width).fill(Infinity));
}
// Convert hex color to ANSI true color escape sequence
hexToAnsi(hex) {
if (!hex || typeof hex !== 'string') return null;
const match = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
if (!match) return null;
const r = parseInt(match[1], 16);
const g = parseInt(match[2], 16);
const b = parseInt(match[3], 16);
return `\x1b[38;2;${r};${g};${b}m`;
}
setPixel(x, y, char, depth = 0, color = null) {
x = Math.floor(x);
y = Math.floor(y);
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
if (depth <= this.depthBuffer[y][x]) {
this.buffer[y][x] = char;
this.colorBuffer[y][x] = color;
this.depthBuffer[y][x] = depth;
}
}
}
getPixel(x, y) {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
return this.buffer[y][x];
}
return ' ';
}
drawLine(x1, y1, x2, y2, char = '#', depth = 0) {
const dx = Math.abs(x2 - x1);
const dy = Math.abs(y2 - y1);
const sx = x1 < x2 ? 1 : -1;
const sy = y1 < y2 ? 1 : -1;
let err = dx - dy;
let x = x1;
let y = y1;
while (true) {
this.setPixel(x, y, char, depth);
if (x === x2 && y === y2) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x += sx;
}
if (e2 < dx) {
err += dx;
y += sy;
}
}
}
drawHorizontalLine(x, y, length, char = '-', depth = 0) {
for (let i = 0; i < length; i++) {
this.setPixel(x + i, y, char, depth);
}
}
drawVerticalLine(x, y, length, char = '|', depth = 0) {
for (let i = 0; i < length; i++) {
this.setPixel(x, y + i, char, depth);
}
}
drawBox(x, y, width, height, char = '#', filled = false, depth = 0) {
if (filled) {
for (let dy = 0; dy < height; dy++) {
for (let dx = 0; dx < width; dx++) {
this.setPixel(x + dx, y + dy, char, depth);
}
}
} else {
// Top and bottom
this.drawHorizontalLine(x, y, width, char, depth);
this.drawHorizontalLine(x, y + height - 1, width, char, depth);
// Left and right
this.drawVerticalLine(x, y, height, char, depth);
this.drawVerticalLine(x + width - 1, y, height, char, depth);
}
}
drawCircle(cx, cy, radius, char = 'o', filled = false, depth = 0) {
if (filled) {
for (let y = -radius; y <= radius; y++) {
for (let x = -radius; x <= radius; x++) {
if (x * x + y * y <= radius * radius) {
this.setPixel(cx + x, cy + y, char, depth);
}
}
}
} else {
let x = radius;
let y = 0;
let err = 0;
while (x >= y) {
this.setPixel(cx + x, cy + y, char, depth);
this.setPixel(cx + y, cy + x, char, depth);
this.setPixel(cx - y, cy + x, char, depth);
this.setPixel(cx - x, cy + y, char, depth);
this.setPixel(cx - x, cy - y, char, depth);
this.setPixel(cx - y, cy - x, char, depth);
this.setPixel(cx + y, cy - x, char, depth);
this.setPixel(cx + x, cy - y, char, depth);
if (err <= 0) {
y += 1;
err += 2 * y + 1;
}
if (err > 0) {
x -= 1;
err -= 2 * x + 1;
}
}
}
}
drawText(x, y, text, colorOrDepth = null, color = null) {
// Support both (x, y, text, color) and (x, y, text, depth, color) signatures
let depth = 0;
let actualColor = null;
if (typeof colorOrDepth === 'string') {
// Called as (x, y, text, color) - color passed as third arg
actualColor = colorOrDepth;
} else if (typeof colorOrDepth === 'number') {
// Called as (x, y, text, depth, color)
depth = colorOrDepth;
actualColor = color;
}
for (let i = 0; i < text.length; i++) {
this.setPixel(x + i, y, text[i], depth, actualColor);
}
}
drawCenteredText(y, text, colorOrDepth = null, color = null) {
const x = Math.floor((this.width - text.length) / 2);
this.drawText(x, y, text, colorOrDepth, color);
}
drawGradientBox(x, y, width, height, startDensity = 0, endDensity = 9, depth = 0) {
for (let dy = 0; dy < height; dy++) {
let densityIdx = Math.floor(startDensity + (endDensity - startDensity) * dy / height);
densityIdx = Math.max(0, Math.min(9, densityIdx));
const char = FrameBuffer.DENSITY_CHARS[densityIdx];
for (let dx = 0; dx < width; dx++) {
this.setPixel(x + dx, y + dy, char, depth);
}
}
}
drawStar(x, y, char = '*', depth = 0) {
this.setPixel(x, y, char, depth);
}
drawParticles(particles, depth = 0) {
for (const [px, py, char] of particles) {
this.setPixel(px, py, char, depth);
}
}
fillCanvas(char = '#', depth = 0) {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
this.setPixel(x, y, char, depth);
}
}
}
clearBox(x, y, width, height, char = ' ') {
for (let dy = 0; dy < height; dy++) {
for (let dx = 0; dx < width; dx++) {
if (x + dx >= 0 && x + dx < this.width && y + dy >= 0 && y + dy < this.height) {
this.buffer[y + dy][x + dx] = char;
this.depthBuffer[y + dy][x + dx] = Infinity;
}
}
}
}
fillExceptBox(excludeX, excludeY, excludeWidth, excludeHeight, char = '#', depth = 0) {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (x >= excludeX && x < excludeX + excludeWidth &&
y >= excludeY && y < excludeY + excludeHeight) {
continue;
}
this.setPixel(x, y, char, depth);
}
}
}
fillExceptCircle(cx, cy, radius, char = '#', depth = 0) {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const dx = (x - cx) * 2.16; // 13/6 aspect ratio correction
const dy = y - cy;
if (dx * dx + dy * dy <= radius * radius) {
continue;
}
this.setPixel(x, y, char, depth);
}
}
}
drawLargeText(x, y, text, depth = 0) {
text = text.toUpperCase();
let xOffset = 0;
for (const char of text) {
const charToUse = FrameBuffer.FIGLET_FONT[char] ? char : ' ';
const charLines = FrameBuffer.FIGLET_FONT[charToUse];
for (let rowIdx = 0; rowIdx < charLines.length; rowIdx++) {
const line = charLines[rowIdx];
for (let colIdx = 0; colIdx < line.length; colIdx++) {
if (line[colIdx] !== ' ') {
this.setPixel(x + xOffset + colIdx, y + rowIdx, line[colIdx], depth);
}
}
}
xOffset += 6; // 5 character width + 1 space
}
}
drawLargeTextCentered(y, text, depth = 0) {
const textWidth = text.length * 6;
const x = Math.floor((this.width - textWidth) / 2);
this.drawLargeText(x, y, text, depth);
}
blit() {
// Move cursor to top-left
process.stdout.write('\x1b[H');
// Write the buffer with colors
const RESET = '\x1b[0m';
for (let y = 0; y < this.height; y++) {
let line = '';
let currentColor = null;
for (let x = 0; x < this.width; x++) {
const char = this.buffer[y][x];
const color = this.colorBuffer[y][x];
if (color !== currentColor) {
if (color) {
const ansi = this.hexToAnsi(color);
if (ansi) {
line += ansi;
}
} else if (currentColor) {
line += RESET;
}
currentColor = color;
}
line += char;
}
if (currentColor) {
line += RESET;
}
process.stdout.write(line + '\n');
}
}
getFrameString() {
return this.buffer.map(row => row.join('')).join('\n');
}
}
class AnimationEngine {
constructor(width = null, height = null) {
if (width === null || height === null) {
this.width = width || process.stdout.columns || 80;
this.height = height || (process.stdout.rows || 24) - 2;
} else {
this.width = width;
this.height = height;
}
this.fb = new FrameBuffer(this.width, this.height);
this.frameCount = 0;
}
clearScreen() {
process.stdout.write('\x1b[2J');
process.stdout.write('\x1b[H');
}
hideCursor() {
process.stdout.write('\x1b[?25l');
}
showCursor() {
process.stdout.write('\x1b[?25h');
}
renderFrame(frameFunc, frameNum, fps = 24) {
this.fb.clear();
frameFunc(this.fb, frameNum);
this.fb.blit();
return new Promise(resolve => setTimeout(resolve, 1000 / fps));
}
async playAnimation(frameFunc, numFrames, fps = 24) {
this.clearScreen();
this.hideCursor();
try {
for (let i = 0; i < numFrames; i++) {
await this.renderFrame(frameFunc, i, fps);
this.frameCount++;
}
} finally {
this.showCursor();
}
}
}
// Utility functions for common patterns
function interpolate(start, end, t) {
return start + (end - start) * t;
}
function easeInOut(t) {
return t * t * (3 - 2 * t);
}
function rotatePoint(x, y, cx, cy, angle) {
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
const dx = x - cx;
const dy = y - cy;
return [
cx + dx * cosA - dy * sinA,
cy + dx * sinA + dy * cosA
];
}
export {
Point,
FrameBuffer,
AnimationEngine,
interpolate,
easeInOut,
rotatePoint
};

View File

@@ -0,0 +1,996 @@
/* eslint-disable */
/**
* Awards Show Effects
* Specialized effects for the awards show vibe
*/
(function() {
// Easing functions
function easeOut(t) {
return 1 - Math.pow(1 - t, 3);
}
function easeInOut(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// ============================================================================
// TROPHY DISPLAY - ASCII trophy art
// ============================================================================
const TROPHY_GRAND = [
' ___ ',
' | | ',
' /| |\\ ',
' / |___| \\ ',
' | / \\ | ',
' \\/_____\\/ ',
' | | ',
' / \\ ',
' /_____\\ ',
];
const TROPHY_SIMPLE = [
' \\___/',
' | |',
' |___|',
' | | ',
' /___\\',
];
const TROPHY_STAR = [
' * ',
' *** ',
' ***** ',
' ******* ',
' *** ',
' | | ',
' /___\\ ',
];
/**
* Draw a trophy display
* @param fb - Framebuffer
* @param x - X position (center)
* @param y - Y position (top)
* @param options - { label, style, centered }
* @param progress - Animation progress 0-1
* @param frame - Current frame for effects
*/
function trophyDisplay(fb, x, y, options, progress, frame) {
const { label = '', style = 'grand', centered = true, depth = 5 } = options;
const trophy = style === 'simple' ? TROPHY_SIMPLE
: style === 'star' ? TROPHY_STAR
: TROPHY_GRAND;
const easedProgress = easeOut(progress);
const visibleLines = Math.floor(easedProgress * trophy.length);
const trophyWidth = Math.max(...trophy.map(line => line.length));
const startX = centered ? x - Math.floor(trophyWidth / 2) : x;
// Draw trophy lines
for (let i = 0; i < visibleLines; i++) {
const line = trophy[i];
const lineX = startX + Math.floor((trophyWidth - line.length) / 2);
for (let j = 0; j < line.length; j++) {
if (line[j] !== ' ') {
fb.setPixel(lineX + j, y + i, line[j], depth + 2);
}
}
}
// Draw label below trophy
if (label && easedProgress > 0.8) {
const labelProgress = (easedProgress - 0.8) / 0.2;
const visibleLabel = label.slice(0, Math.floor(labelProgress * label.length));
const labelX = centered ? x - Math.floor(visibleLabel.length / 2) : x;
for (let i = 0; i < visibleLabel.length; i++) {
fb.setPixel(labelX + i, y + trophy.length + 1, visibleLabel[i], depth + 1);
}
}
return trophy.length + 2;
}
// ============================================================================
// AWARD BADGE - Medal/badge display
// ============================================================================
/**
* Draw an award badge/medal
* @param fb - Framebuffer
* @param x - X position (center)
* @param y - Y position (top)
* @param options - { category, year, style }
* @param progress - Animation progress 0-1
*/
function awardBadge(fb, x, y, options, progress) {
const { category = 'AWARD', year = '2024', style = 'gold', depth = 5 } = options;
const easedProgress = easeOut(progress);
if (easedProgress < 0.1) return;
const starChar = style === 'gold' ? '★' : style === 'silver' ? '☆' : '✦';
const borderH = style === 'gold' ? '═' : '─';
const cornerTL = style === 'gold' ? '╔' : '┌';
const cornerTR = style === 'gold' ? '╗' : '┐';
const cornerBL = style === 'gold' ? '╚' : '└';
const cornerBR = style === 'gold' ? '╝' : '┘';
const sideV = style === 'gold' ? '║' : '│';
const yearLine = ` ${starChar} ${year} ${starChar} `;
const catLine = ` ${category} `;
const width = Math.max(yearLine.length, catLine.length) + 2;
const startX = x - Math.floor(width / 2);
const visibleWidth = Math.floor(easedProgress * width);
// Top border
fb.setPixel(startX, y, cornerTL, depth);
for (let i = 1; i < Math.min(visibleWidth - 1, width - 1); i++) {
fb.setPixel(startX + i, y, borderH, depth);
}
if (visibleWidth >= width) fb.setPixel(startX + width - 1, y, cornerTR, depth);
// Year line
if (easedProgress > 0.3) {
fb.setPixel(startX, y + 1, sideV, depth);
const yearProgress = Math.min(1, (easedProgress - 0.3) / 0.3);
const yearPadded = yearLine.padStart(Math.floor((width - 2 + yearLine.length) / 2)).padEnd(width - 2);
const visibleYear = yearPadded.slice(0, Math.floor(yearProgress * yearPadded.length));
for (let i = 0; i < visibleYear.length; i++) {
fb.setPixel(startX + 1 + i, y + 1, visibleYear[i], depth + 1);
}
fb.setPixel(startX + width - 1, y + 1, sideV, depth);
}
// Category line
if (easedProgress > 0.5) {
fb.setPixel(startX, y + 2, sideV, depth);
const catProgress = Math.min(1, (easedProgress - 0.5) / 0.3);
const catPadded = catLine.padStart(Math.floor((width - 2 + catLine.length) / 2)).padEnd(width - 2);
const visibleCat = catPadded.slice(0, Math.floor(catProgress * catPadded.length));
for (let i = 0; i < visibleCat.length; i++) {
fb.setPixel(startX + 1 + i, y + 2, visibleCat[i], depth + 2);
}
fb.setPixel(startX + width - 1, y + 2, sideV, depth);
}
// Bottom border
if (easedProgress > 0.8) {
fb.setPixel(startX, y + 3, cornerBL, depth);
for (let i = 1; i < width - 1; i++) {
fb.setPixel(startX + i, y + 3, borderH, depth);
}
fb.setPixel(startX + width - 1, y + 3, cornerBR, depth);
}
return 4;
}
// ============================================================================
// ENVELOPE REVEAL - Dramatic envelope opening
// ============================================================================
/**
* Draw envelope reveal animation
* @param fb - Framebuffer
* @param winnerName - Name to reveal
* @param progress - Animation progress 0-1
* @param frame - Current frame for effects
* @param options - { y, suspenseText }
*/
function envelopeReveal(fb, winnerName, progress, frame, options = {}) {
const {
y = 10,
suspenseText = 'AND THE AWARD GOES TO...',
depth = 5,
} = options;
const easedProgress = easeOut(progress);
const centerX = Math.floor(fb.width / 2);
// Phase 1: Suspense text (0-0.4)
if (progress < 0.4) {
const textProgress = progress / 0.4;
const visibleChars = Math.floor(textProgress * suspenseText.length);
const visibleText = suspenseText.slice(0, visibleChars);
const textX = centerX - Math.floor(suspenseText.length / 2);
for (let i = 0; i < visibleText.length; i++) {
fb.setPixel(textX + i, y, visibleText[i], depth);
}
// Blinking cursor
if (textProgress < 1) {
const cursorChar = Math.floor(frame / 6) % 2 === 0 ? '█' : ' ';
fb.setPixel(textX + visibleChars, y, cursorChar, depth);
}
}
// Phase 2: Envelope appears and opens (0.4-0.7)
if (progress >= 0.4 && progress < 0.7) {
const envProgress = (progress - 0.4) / 0.3;
// Draw envelope
const envWidth = 30;
const envX = centerX - Math.floor(envWidth / 2);
const envY = y + 2;
// Envelope body
const envHeight = 5;
for (let row = 0; row < envHeight; row++) {
fb.setPixel(envX, envY + row, row === 0 ? '╭' : row === envHeight - 1 ? '╰' : '│', depth);
for (let col = 1; col < envWidth - 1; col++) {
if (row === 0) {
fb.setPixel(envX + col, envY + row, '─', depth);
} else if (row === envHeight - 1) {
fb.setPixel(envX + col, envY + row, '─', depth);
} else {
fb.setPixel(envX + col, envY + row, ' ', depth);
}
}
fb.setPixel(envX + envWidth - 1, envY + row, row === 0 ? '╮' : row === envHeight - 1 ? '╯' : '│', depth);
}
// Opening flap animation
if (envProgress > 0.5) {
const flapProgress = (envProgress - 0.5) / 0.5;
const flapChars = ['▔', '▀', '█'];
const flapIdx = Math.min(flapChars.length - 1, Math.floor(flapProgress * flapChars.length));
for (let col = 1; col < envWidth - 1; col++) {
fb.setPixel(envX + col, envY, flapChars[flapIdx], depth + 1);
}
}
}
// Phase 3: Winner name reveal (0.7-1.0)
if (progress >= 0.7) {
const nameProgress = (progress - 0.7) / 0.3;
const easedNameProgress = easeOut(nameProgress);
// Winner name with dramatic reveal
const nameY = y + 4;
const nameX = centerX - Math.floor(winnerName.length / 2);
// Reveal from center outward
const halfLen = Math.ceil(winnerName.length / 2);
const visibleFromCenter = Math.floor(easedNameProgress * halfLen);
for (let i = 0; i < winnerName.length; i++) {
const distFromCenter = Math.abs(i - Math.floor(winnerName.length / 2));
if (distFromCenter <= visibleFromCenter) {
fb.setPixel(nameX + i, nameY, winnerName[i], depth + 3);
}
}
// Decorative sparkles
if (nameProgress > 0.5) {
const sparkleChars = ['✦', '*', '·'];
const sparkleX1 = nameX - 3;
const sparkleX2 = nameX + winnerName.length + 2;
const sparkleIdx = Math.floor(frame / 4) % sparkleChars.length;
fb.setPixel(sparkleX1, nameY, sparkleChars[sparkleIdx], depth + 1);
fb.setPixel(sparkleX2, nameY, sparkleChars[sparkleIdx], depth + 1);
}
}
}
// ============================================================================
// CATEGORY TITLE - Animated category header
// ============================================================================
/**
* Draw a category title header
* @param fb - Framebuffer
* @param y - Y position
* @param title - Category title
* @param progress - Animation progress 0-1
* @param frame - Current frame for effects
* @param options - { style }
*/
function categoryTitle(fb, y, title, progress, frame, options = {}) {
const { style = 'grand', depth = 5 } = options;
const easedProgress = easeOut(progress);
const centerX = Math.floor(fb.width / 2);
if (style === 'grand') {
// Top decorative line
const lineWidth = Math.floor(easedProgress * 40);
const lineX = centerX - Math.floor(lineWidth / 2);
if (lineWidth > 0) {
for (let i = 0; i < lineWidth; i++) {
fb.setPixel(lineX + i, y, '═', depth);
}
}
// Title with stars
if (easedProgress > 0.3) {
const titleProgress = (easedProgress - 0.3) / 0.5;
const fullTitle = `${title}`;
const visibleTitle = fullTitle.slice(0, Math.floor(titleProgress * fullTitle.length));
const titleX = centerX - Math.floor(fullTitle.length / 2);
for (let i = 0; i < visibleTitle.length; i++) {
fb.setPixel(titleX + i, y + 1, visibleTitle[i], depth + 2);
}
}
// Bottom decorative line
if (easedProgress > 0.6) {
const bottomProgress = (easedProgress - 0.6) / 0.4;
const bottomWidth = Math.floor(bottomProgress * 40);
const bottomX = centerX - Math.floor(bottomWidth / 2);
for (let i = 0; i < bottomWidth; i++) {
fb.setPixel(bottomX + i, y + 2, '═', depth);
}
}
} else if (style === 'simple') {
const titleX = centerX - Math.floor(title.length / 2);
const visibleTitle = title.slice(0, Math.floor(easedProgress * title.length));
for (let i = 0; i < visibleTitle.length; i++) {
fb.setPixel(titleX + i, y, visibleTitle[i], depth + 1);
}
} else {
// minimal
const fullTitle = `[ ${title} ]`;
const titleX = centerX - Math.floor(fullTitle.length / 2);
const visibleTitle = fullTitle.slice(0, Math.floor(easedProgress * fullTitle.length));
for (let i = 0; i < visibleTitle.length; i++) {
fb.setPixel(titleX + i, y, visibleTitle[i], depth);
}
}
}
// ============================================================================
// ACCEPTANCE SPEECH - Full project spotlight for awards
// ============================================================================
/**
* Draw a full acceptance speech display for a project
* @param fb - Framebuffer
* @param project - { name, commits, description?, body?, rank? }
* @param progress - Animation progress 0-1
* @param frame - Current frame for effects
* @param options - { y, width, showTrophy }
*/
function acceptanceSpeech(fb, project, progress, frame, options = {}) {
const {
y = 4,
width = 50,
showTrophy = true,
depth = 5,
centered = true,
} = options;
const { name, commits, description, body, rank } = project;
const easedProgress = easeOut(progress);
if (easedProgress < 0.05) return;
const centerX = Math.floor(fb.width / 2);
const boxX = centered ? centerX - Math.floor(width / 2) : 2;
let currentY = y;
// Draw trophy above the box
if (showTrophy && easedProgress > 0.05) {
const trophyProgress = Math.min(1, (easedProgress - 0.05) / 0.2);
trophyDisplay(fb, centerX, currentY, {
label: '',
style: rank === 1 ? 'grand' : rank === 2 ? 'simple' : 'star',
}, trophyProgress, frame);
currentY += rank === 1 ? 10 : 6;
}
// Calculate visible width for animation
const visibleWidth = Math.floor(Math.min(1, (easedProgress - 0.2) / 0.3) * width);
if (visibleWidth < 5) return;
// Helper to draw a row with side borders
function drawRow(text, textDepth = depth) {
fb.setPixel(boxX, currentY, '║', depth);
for (let i = 0; i < text.length && i < visibleWidth - 2; i++) {
fb.setPixel(boxX + 1 + i, currentY, text[i], textDepth);
}
// Fill remaining space
for (let i = text.length; i < visibleWidth - 2; i++) {
fb.setPixel(boxX + 1 + i, currentY, ' ', depth);
}
if (visibleWidth > 1) fb.setPixel(boxX + visibleWidth - 1, currentY, '║', depth);
currentY++;
}
// Top border with flair
if (easedProgress > 0.25) {
fb.setPixel(boxX, currentY, '╔', depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(boxX + i, currentY, '═', depth);
}
if (visibleWidth > 1) fb.setPixel(boxX + visibleWidth - 1, currentY, '╗', depth);
currentY++;
}
// Award category line
if (rank && easedProgress > 0.3) {
const rankLabel = rank === 1 ? '★ BEST PROJECT ★' : rank === 2 ? '☆ RUNNER UP ☆' : '✦ HONORABLE MENTION ✦';
drawRow(` ${rankLabel}`, depth + 1);
}
// Project name
if (easedProgress > 0.4) {
const nameProgress = Math.min(1, (easedProgress - 0.4) / 0.15);
const visibleName = name.slice(0, Math.floor(nameProgress * name.length));
drawRow(` ${visibleName}`, depth + 3);
}
// Divider after name
if (easedProgress > 0.5) {
fb.setPixel(boxX, currentY, '╟', depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(boxX + i, currentY, '─', depth);
}
if (visibleWidth > 1) fb.setPixel(boxX + visibleWidth - 1, currentY, '╢', depth);
currentY++;
}
// Commits stat with animated counter
if (easedProgress > 0.55) {
const counterProgress = Math.min(1, (easedProgress - 0.55) / 0.1);
const displayCommits = Math.floor(counterProgress * commits);
drawRow(` ${displayCommits.toLocaleString()} COMMITS`, depth + 2);
}
// Description
if (description && easedProgress > 0.6) {
const descProgress = Math.min(1, (easedProgress - 0.6) / 0.1);
const maxDesc = visibleWidth - 4;
const displayDesc = description.slice(0, Math.min(maxDesc, Math.floor(descProgress * description.length)));
drawRow(` ${displayDesc}`, depth);
}
// Divider before body
if (body && easedProgress > 0.65) {
fb.setPixel(boxX, currentY, '║', depth);
for (let i = 1; i < Math.min(visibleWidth - 1, 20); i++) {
fb.setPixel(boxX + i, currentY, '·', depth);
}
for (let i = 20; i < visibleWidth - 1; i++) {
fb.setPixel(boxX + i, currentY, ' ', depth);
}
if (visibleWidth > 1) fb.setPixel(boxX + visibleWidth - 1, currentY, '║', depth);
currentY++;
}
// Body text (acceptance speech content)
if (body && easedProgress > 0.7) {
const bodyProgress = Math.min(1, (easedProgress - 0.7) / 0.25);
const maxLineWidth = visibleWidth - 6;
const words = body.split(' ');
const lines = [];
let currentLine = '';
for (const word of words) {
if ((currentLine + ' ' + word).trim().length > maxLineWidth) {
if (currentLine) lines.push(currentLine);
currentLine = word;
} else {
currentLine = currentLine ? currentLine + ' ' + word : word;
}
}
if (currentLine) lines.push(currentLine);
// Draw visible portion of body
const totalChars = lines.join('').length;
let charsDrawn = 0;
const charsToShow = Math.floor(bodyProgress * totalChars);
for (const line of lines) {
if (charsDrawn >= charsToShow) break;
const lineCharsToShow = Math.min(line.length, charsToShow - charsDrawn);
const visibleLine = line.slice(0, lineCharsToShow);
drawRow(` ${visibleLine}`, depth);
charsDrawn += line.length;
}
}
// Bottom border
if (easedProgress > 0.9) {
fb.setPixel(boxX, currentY, '╚', depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(boxX + i, currentY, '═', depth);
}
if (visibleWidth > 1) fb.setPixel(boxX + visibleWidth - 1, currentY, '╝', depth);
}
}
// ============================================================================
// NOMINEE CARD - Display a nominee before winner announcement
// ============================================================================
/**
* Draw a nominee card
* @param fb - Framebuffer
* @param x - X position
* @param y - Y position
* @param nominee - { name, stat, statLabel }
* @param progress - Animation progress 0-1
* @param options - { style, width }
*/
function nomineeCard(fb, x, y, nominee, progress, options = {}) {
const { style = 'elegant', width = 25, depth = 5 } = options;
const { name, stat, statLabel } = nominee;
const easedProgress = easeOut(progress);
if (easedProgress < 0.1) return;
const visibleWidth = Math.floor(easedProgress * width);
if (visibleWidth < 5) return;
let currentY = y;
// Border top
const borderH = style === 'elegant' ? '─' : '═';
const cornerTL = style === 'elegant' ? '╭' : '╔';
const cornerTR = style === 'elegant' ? '╮' : '╗';
const cornerBL = style === 'elegant' ? '╰' : '╚';
const cornerBR = style === 'elegant' ? '╯' : '╝';
const sideV = style === 'elegant' ? '│' : '║';
fb.setPixel(x, currentY, cornerTL, depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(x + i, currentY, borderH, depth);
}
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, cornerTR, depth);
currentY++;
// Name
if (easedProgress > 0.3) {
fb.setPixel(x, currentY, sideV, depth);
const displayName = name.slice(0, visibleWidth - 4);
for (let i = 0; i < displayName.length; i++) {
fb.setPixel(x + 2 + i, currentY, displayName[i], depth + 2);
}
fb.setPixel(x + visibleWidth - 1, currentY, sideV, depth);
currentY++;
}
// Stat
if (stat && easedProgress > 0.5) {
fb.setPixel(x, currentY, sideV, depth);
const statStr = typeof stat === 'number' ? stat.toLocaleString() : stat;
const statText = statLabel ? `${statStr} ${statLabel}` : statStr;
for (let i = 0; i < statText.length && i < visibleWidth - 4; i++) {
fb.setPixel(x + 2 + i, currentY, statText[i], depth + 1);
}
fb.setPixel(x + visibleWidth - 1, currentY, sideV, depth);
currentY++;
}
// Border bottom
if (easedProgress > 0.7) {
fb.setPixel(x, currentY, cornerBL, depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(x + i, currentY, borderH, depth);
}
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, cornerBR, depth);
}
}
// ============================================================================
// WINNER ANNOUNCEMENT - Full reveal sequence
// ============================================================================
/**
* Draw a complete winner announcement sequence
* @param fb - Framebuffer
* @param winnerName - Winner name
* @param progress - Animation progress 0-1
* @param frame - Current frame
* @param options - { category, stat, statLabel }
*/
function winnerAnnouncement(fb, winnerName, progress, frame, options = {}) {
const { category = 'WINNER', stat, statLabel, depth = 5 } = options;
const centerX = Math.floor(fb.width / 2);
const centerY = Math.floor(fb.height / 2);
// Phase 1: Category reveal (0-0.25)
if (progress < 0.25) {
categoryTitle(fb, centerY - 4, category, progress / 0.25, frame, { style: 'grand' });
}
// Phase 2: Envelope reveal (0.25-0.6)
if (progress >= 0.25 && progress < 0.6) {
const envProgress = (progress - 0.25) / 0.35;
envelopeReveal(fb, winnerName, envProgress, frame, { y: centerY - 2 });
}
// Phase 3: Winner celebration (0.6-1.0)
if (progress >= 0.6) {
const celebProgress = (progress - 0.6) / 0.4;
// Winner name with glow effect
const nameX = centerX - Math.floor(winnerName.length / 2);
for (let i = 0; i < winnerName.length; i++) {
fb.setPixel(nameX + i, centerY, winnerName[i], depth + 5);
}
// Sparkle effects around name
if (celebProgress > 0.2) {
const sparkleChars = ['✦', '*', '·', '★'];
const sparklePositions = [
{ x: nameX - 4, y: centerY },
{ x: nameX + winnerName.length + 3, y: centerY },
{ x: nameX - 2, y: centerY - 1 },
{ x: nameX + winnerName.length + 1, y: centerY - 1 },
{ x: nameX - 2, y: centerY + 1 },
{ x: nameX + winnerName.length + 1, y: centerY + 1 },
];
sparklePositions.forEach((pos, idx) => {
const sparkleIdx = (Math.floor(frame / 4) + idx) % sparkleChars.length;
fb.setPixel(pos.x, pos.y, sparkleChars[sparkleIdx], depth + 1);
});
}
// Stat below name
if (stat && celebProgress > 0.4) {
const statProgress = (celebProgress - 0.4) / 0.4;
const statStr = typeof stat === 'number'
? Math.floor(statProgress * stat).toLocaleString()
: stat;
const statText = statLabel ? `${statStr} ${statLabel}` : statStr;
const statX = centerX - Math.floor(statText.length / 2);
for (let i = 0; i < statText.length; i++) {
fb.setPixel(statX + i, centerY + 2, statText[i], depth + 2);
}
}
}
}
// ============================================================================
// APPLAUSE METER - Visual audience reaction
// ============================================================================
/**
* Draw an applause meter
* @param fb - Framebuffer
* @param y - Y position
* @param progress - Animation progress 0-1
* @param frame - Current frame for animation
* @param options - { intensity }
*/
function applauseMeter(fb, y, progress, frame, options = {}) {
const { intensity = 0.8, depth = 5 } = options;
const width = 30;
const centerX = Math.floor(fb.width / 2);
const startX = centerX - Math.floor(width / 2);
const easedProgress = easeOut(progress);
const filledWidth = Math.floor(easedProgress * intensity * width);
// Animated applause chars
const applauseChars = ['👏', '✋', '🙌', '✨'];
const activeChar = applauseChars[Math.floor(frame / 6) % applauseChars.length];
// Draw the meter
for (let i = 0; i < width; i++) {
if (i < filledWidth) {
// Filled portion - wave effect
const waveOffset = Math.sin((frame * 0.3) + (i * 0.5)) * 0.3;
const charIdx = Math.floor((i + frame * 0.2) % applauseChars.length);
fb.setPixel(startX + i, y, applauseChars[charIdx], depth + 2);
} else {
fb.setPixel(startX + i, y, '░', depth);
}
}
}
// ============================================================================
// STANDING OVATION - Celebration particle effect
// ============================================================================
/**
* Standing ovation particle effect
* @param fb - Framebuffer
* @param frame - Current frame
* @param options - { intensity, chars }
*/
function standingOvation(fb, frame, options = {}) {
const {
intensity = 1.0,
chars = ['✦', '*', '·', '★', '✧'],
depth = 3,
} = options;
const particleCount = Math.floor(intensity * 20);
for (let i = 0; i < particleCount; i++) {
// Rising particles
const seed = i * 12.345 + frame * 0.02;
const x = Math.floor((Math.sin(seed * 7.89) * 0.5 + 0.5) * fb.width);
const baseY = fb.height - 1 - ((frame * 0.3 + i * 3) % fb.height);
const y = Math.floor(baseY + Math.sin(seed * 2.34) * 2);
if (y >= 0 && y < fb.height && x >= 0 && x < fb.width) {
const charIdx = Math.floor((seed * 100) % chars.length);
fb.setPixel(x, y, chars[charIdx], depth);
}
}
}
// ============================================================================
// RED CARPET BORDER - Decorative border
// ============================================================================
/**
* Draw a red carpet style border
* @param fb - Framebuffer
* @param progress - Animation progress 0-1
* @param options - { style }
*/
function redCarpetBorder(fb, progress, options = {}) {
const { style = 'velvet', depth = 2 } = options;
const easedProgress = easeOut(progress);
const topY = 0;
const bottomY = fb.height - 1;
const leftX = 0;
const rightX = fb.width - 1;
const chars = style === 'velvet' ? { h: '═', v: '║', corner: '◆' }
: style === 'gold' ? { h: '─', v: '│', corner: '★' }
: { h: '·', v: '·', corner: '✦' };
// Animate from corners
const visibleH = Math.floor(easedProgress * fb.width / 2);
const visibleV = Math.floor(easedProgress * fb.height / 2);
// Corners
fb.setPixel(leftX, topY, chars.corner, depth + 1);
fb.setPixel(rightX, topY, chars.corner, depth + 1);
fb.setPixel(leftX, bottomY, chars.corner, depth + 1);
fb.setPixel(rightX, bottomY, chars.corner, depth + 1);
// Top and bottom borders
for (let i = 1; i <= visibleH; i++) {
fb.setPixel(leftX + i, topY, chars.h, depth);
fb.setPixel(rightX - i, topY, chars.h, depth);
fb.setPixel(leftX + i, bottomY, chars.h, depth);
fb.setPixel(rightX - i, bottomY, chars.h, depth);
}
// Left and right borders
for (let i = 1; i <= visibleV; i++) {
fb.setPixel(leftX, topY + i, chars.v, depth);
fb.setPixel(leftX, bottomY - i, chars.v, depth);
fb.setPixel(rightX, topY + i, chars.v, depth);
fb.setPixel(rightX, bottomY - i, chars.v, depth);
}
}
// ============================================================================
// SPOTLIGHT TEXT - Glowing text effect
// ============================================================================
/**
* Draw text with spotlight/glow effect
* @param fb - Framebuffer
* @param y - Y position
* @param text - Text to display
* @param frame - Current frame for animation
* @param options - { glow, centered }
*/
function spotlightText(fb, y, text, frame, options = {}) {
const { glow = true, centered = true, depth = 5 } = options;
const x = centered ? Math.floor((fb.width - text.length) / 2) : 2;
// Draw main text
for (let i = 0; i < text.length; i++) {
fb.setPixel(x + i, y, text[i], depth + 3);
}
// Glow effect - subtle sparkles around text
if (glow) {
const glowChars = ['·', '*', '✦'];
const glowPositions = [
{ dx: -1, dy: 0 },
{ dx: text.length, dy: 0 },
{ dx: 0, dy: -1 },
{ dx: text.length - 1, dy: -1 },
{ dx: 0, dy: 1 },
{ dx: text.length - 1, dy: 1 },
];
glowPositions.forEach((pos, idx) => {
const glowX = x + pos.dx;
const glowY = y + pos.dy;
if (glowX >= 0 && glowX < fb.width && glowY >= 0 && glowY < fb.height) {
const charIdx = (Math.floor(frame / 5) + idx) % glowChars.length;
fb.setPixel(glowX, glowY, glowChars[charIdx], depth);
}
});
}
}
// ============================================================================
// SPOTLIGHT REVEAL - Circle reveal from center with spotlight feel
// ============================================================================
/**
* Spotlight reveal transition
* @param fb - Framebuffer
* @param progress - Transition progress 0-1
* @param options - { x, y } center point
*/
function spotlightReveal(fb, progress, options = {}) {
const {
x = Math.floor(fb.width / 2),
y = Math.floor(fb.height / 2),
} = options;
const maxRadius = Math.sqrt(fb.width * fb.width + fb.height * fb.height);
const radius = easeOut(progress) * maxRadius;
for (let py = 0; py < fb.height; py++) {
for (let px = 0; px < fb.width; px++) {
const dist = Math.sqrt((px - x) ** 2 + ((py - y) * 2) ** 2);
if (dist > radius) {
fb.setPixel(px, py, ' ', -100);
}
}
}
}
// ============================================================================
// CURTAIN REVEAL - Theater curtain opening
// ============================================================================
/**
* Curtain reveal transition
* @param fb - Framebuffer
* @param progress - Transition progress 0-1
*/
function curtainReveal(fb, progress) {
const easedProgress = easeOut(progress);
const centerX = Math.floor(fb.width / 2);
const openWidth = Math.floor(easedProgress * centerX);
// Clear areas outside the "opening"
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
if (x < centerX - openWidth || x > centerX + openWidth) {
fb.setPixel(x, y, '█', -50);
}
}
// Curtain edge effect
if (openWidth > 0 && openWidth < centerX) {
const edgeChars = ['│', '┃', '║'];
const leftEdge = centerX - openWidth;
const rightEdge = centerX + openWidth;
if (leftEdge >= 0) fb.setPixel(leftEdge, y, edgeChars[1], -40);
if (rightEdge < fb.width) fb.setPixel(rightEdge, y, edgeChars[1], -40);
}
}
}
// ============================================================================
// AWARDS STATUE - Large decorative trophy ASCII art
// ============================================================================
const OSCAR_STATUE = [
' ▄▄ ',
' ████ ',
' ████ ',
' ██████ ',
' ████████ ',
' ██████████ ',
' ██ ██ ██ ',
' ██ ',
' ████ ',
' ██████ ',
' ████████ ',
' ██ ',
' ██████ ',
' ████████ ',
' ██████████ ',
];
const GLOBE_STATUE = [
' ╭────╮ ',
' ╭─┤ ├─╮ ',
' │ ╰────╯ │ ',
' │ ╭────╮ │ ',
' ╰─┤ ├─╯ ',
' ╰────╯ ',
' ││ ',
' ╭────╮ ',
' ╭──────╮ ',
' ╭────────╮ ',
];
const STAR_STATUE = [
' ★ ',
' ★★★ ',
' ★★★★★ ',
' ★★★★★★★ ',
' ★★★★★★★★★ ',
' ★★★★★ ',
' ★★★ ',
' ★ ',
' ███ ',
' █████ ',
' ███████ ',
];
/**
* Draw a large awards statue
* @param fb - Framebuffer
* @param x - X position (center)
* @param y - Y position (top)
* @param progress - Animation progress 0-1
* @param options - { style }
*/
function awardsStatue(fb, x, y, progress, options = {}) {
const { style = 'oscar', depth = 5 } = options;
const statue = style === 'globe' ? GLOBE_STATUE
: style === 'star' ? STAR_STATUE
: OSCAR_STATUE;
if (!statue || !statue.length) return;
const easedProgress = easeOut(Math.min(1, progress));
const visibleLines = Math.min(statue.length, Math.floor(easedProgress * statue.length));
const statueWidth = Math.max(...statue.map(line => line.length));
const startX = x - Math.floor(statueWidth / 2);
for (let i = 0; i < visibleLines; i++) {
const line = statue[i];
if (!line) continue;
const lineX = startX + Math.floor((statueWidth - line.length) / 2);
for (let j = 0; j < line.length; j++) {
if (line[j] !== ' ') {
fb.setPixel(lineX + j, y + i, line[j], depth + 2);
}
}
}
}
// Make available globally
if (typeof globalThis !== 'undefined') {
Object.assign(globalThis, {
trophyDisplay,
awardBadge,
envelopeReveal,
categoryTitle,
acceptanceSpeech,
nomineeCard,
winnerAnnouncement,
applauseMeter,
standingOvation,
redCarpetBorder,
spotlightText,
spotlightReveal,
curtainReveal,
awardsStatue,
});
}
})();

View File

@@ -0,0 +1,324 @@
/* eslint-disable */
/**
* Background Effects
* Animated backgrounds for scene atmosphere
* All functions take (fb, frame, options)
*/
(function() {
// Seeded random for consistent patterns
function seededRandom(seed) {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
const DENSITY_CHARS = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
/**
* Twinkling stars background
*/
function stars(fb, frame, options = {}) {
const { density = 0.006, twinkle = true, depth = 100 } = options;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const seed = x * 31 + y * 17;
const rand = seededRandom(seed);
if (rand < density) {
// Twinkle effect based on frame
const twinkleSeed = seed + Math.floor(frame / 8);
const isTwinkling = twinkle && seededRandom(twinkleSeed) > 0.7;
const char = isTwinkling ? '·' : '.';
fb.setPixel(x, y, char, depth);
}
}
}
}
/**
* 3D starfield zoom effect
*/
function starfield(fb, frame, options = {}) {
const { speed = 1, numStars = 50, depth = 100 } = options;
const centerX = fb.width / 2;
const centerY = fb.height / 2;
const aspectRatio = 2.16;
for (let i = 0; i < numStars; i++) {
const seed = i * 17;
// Star position in normalized space (-1 to 1)
const baseX = seededRandom(seed) * 2 - 1;
const baseY = seededRandom(seed + 1) * 2 - 1;
// Z position cycles based on frame
const z = ((seededRandom(seed + 2) + frame * speed * 0.01) % 1);
const scale = 1 / (z + 0.1);
const screenX = Math.floor(centerX + baseX * scale * 20 * aspectRatio);
const screenY = Math.floor(centerY + baseY * scale * 10);
if (screenX >= 0 && screenX < fb.width && screenY >= 0 && screenY < fb.height) {
// Brighter stars closer (lower z)
const char = z < 0.3 ? '*' : z < 0.6 ? '·' : '.';
fb.setPixel(screenX, screenY, char, depth);
}
}
}
/**
* Rain effect
*/
function rain(fb, frame, options = {}) {
const { density = 0.02, speed = 1, char = '|', depth = 100 } = options;
for (let x = 0; x < fb.width; x++) {
const columnSeed = x * 31;
const columnDensity = seededRandom(columnSeed) < density * 10 ? 1 : 0;
if (columnDensity) {
const dropSpeed = 0.5 + seededRandom(columnSeed + 1) * speed;
const offset = Math.floor(frame * dropSpeed);
const startY = seededRandom(columnSeed + 2) * fb.height;
for (let len = 0; len < 3; len++) {
const y = Math.floor((startY + offset + len) % fb.height);
const dropChar = len === 0 ? char : (len === 1 ? ':' : '.');
fb.setPixel(x, y, dropChar, depth);
}
}
}
}
/**
* Snow effect
*/
function snow(fb, frame, options = {}) {
const { density = 0.01, chars = ['*', '·', '.'], depth = 100 } = options;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const seed = x * 31 + y * 17;
if (seededRandom(seed) < density) {
// Gentle falling motion with slight horizontal drift
const fallSpeed = 0.3 + seededRandom(seed + 1) * 0.3;
const drift = Math.sin((frame + seed) * 0.1) * 2;
const offsetY = Math.floor(frame * fallSpeed);
const offsetX = Math.floor(drift);
const drawY = (y + offsetY) % fb.height;
const drawX = ((x + offsetX) % fb.width + fb.width) % fb.width;
const charIdx = Math.floor(seededRandom(seed + 2) * chars.length);
fb.setPixel(drawX, drawY, chars[charIdx], depth);
}
}
}
}
/**
* Fog/mist effect
*/
function fog(fb, frame, options = {}) {
const { density = 0.3, speed = 0.5, depth = 100 } = options;
const fogChars = ['.', ':', '.', ' '];
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const seed = x * 31 + y * 17;
const noise = seededRandom(seed + Math.floor(frame * speed * 0.1));
if (noise < density) {
const charIdx = Math.floor(noise / density * fogChars.length);
fb.setPixel(x, y, fogChars[charIdx], depth);
}
}
}
}
/**
* Aurora/northern lights effect
*/
function aurora(fb, frame, options = {}) {
const { intensity = 0.5, depth = 100 } = options;
const waveChars = ['·', ':', '=', '~', '≈'];
// Aurora bands at different heights
const numBands = 3;
for (let band = 0; band < numBands; band++) {
const baseY = 2 + band * 3;
const phaseOffset = band * 2;
for (let x = 0; x < fb.width; x++) {
const wave = Math.sin((x + frame * 0.5 + phaseOffset) * 0.1) * 2;
const y = Math.floor(baseY + wave);
if (y >= 0 && y < fb.height) {
const charIdx = Math.floor((Math.sin(x * 0.2 + frame * 0.1) + 1) / 2 * waveChars.length);
if (seededRandom(x + band * 100 + frame) < intensity) {
fb.setPixel(x, y, waveChars[charIdx], depth);
}
}
}
}
}
/**
* Wave pattern effect
*/
function waves(fb, frame, options = {}) {
const { amplitude = 2, frequency = 0.1, char = '~', baseY = null, depth = 100 } = options;
const waveY = baseY !== null ? baseY : Math.floor(fb.height / 2);
for (let x = 0; x < fb.width; x++) {
const y = Math.floor(waveY + Math.sin((x + frame) * frequency) * amplitude);
if (y >= 0 && y < fb.height) {
fb.setPixel(x, y, char, depth);
}
}
}
/**
* Gradient fill (vertical, horizontal, or radial)
*/
function gradient(fb, options = {}) {
const { direction = 'vertical', chars = DENSITY_CHARS, depth = 100, invert = false } = options;
const centerX = fb.width / 2;
const centerY = fb.height / 2;
const maxDist = Math.sqrt(centerX * centerX + centerY * centerY);
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
let t;
switch (direction) {
case 'horizontal':
t = x / fb.width;
break;
case 'radial':
const dx = x - centerX;
const dy = y - centerY;
t = Math.sqrt(dx * dx + dy * dy) / maxDist;
break;
case 'vertical':
default:
t = y / fb.height;
}
if (invert) t = 1 - t;
const charIdx = Math.floor(t * (chars.length - 1));
fb.setPixel(x, y, chars[charIdx], depth);
}
}
}
/**
* TV static noise effect
*/
function staticNoise(fb, frame, options = {}) {
const { density = 0.1, chars = ['.', ':', '#'], depth = 100 } = options;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const seed = x * 31 + y * 17 + frame * 7;
if (seededRandom(seed) < density) {
const charIdx = Math.floor(seededRandom(seed + 1) * chars.length);
fb.setPixel(x, y, chars[charIdx], depth);
}
}
}
}
/**
* Concentric ripples effect
*/
function ripples(fb, frame, options = {}) {
const { cx = null, cy = null, speed = 1, char = '·', depth = 100 } = options;
const centerX = cx !== null ? cx : fb.width / 2;
const centerY = cy !== null ? cy : fb.height / 2;
const aspectRatio = 2.16;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const dx = (x - centerX) / aspectRatio;
const dy = y - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
// Ripple pattern
const ripple = Math.sin(dist - frame * speed * 0.5);
if (ripple > 0.8) {
fb.setPixel(x, y, char, depth);
}
}
}
}
/**
* Fireflies effect
*/
function fireflies(fb, frame, options = {}) {
const { count = 8, chars = ['·', '*', '°'], depth = 50 } = options;
for (let i = 0; i < count; i++) {
const seed = i * 31;
// Random base position
const baseX = seededRandom(seed) * fb.width;
const baseY = seededRandom(seed + 1) * fb.height;
// Gentle floating motion
const floatX = Math.sin((frame + seed) * 0.05) * 3;
const floatY = Math.cos((frame + seed * 2) * 0.03) * 2;
const x = Math.floor((baseX + floatX + fb.width) % fb.width);
const y = Math.floor((baseY + floatY + fb.height) % fb.height);
// Blink effect
const blinkPhase = (frame + seed * 7) % 60;
if (blinkPhase < 30) {
const brightness = blinkPhase < 15 ? blinkPhase / 15 : (30 - blinkPhase) / 15;
const charIdx = Math.floor(brightness * (chars.length - 1));
fb.setPixel(x, y, chars[charIdx], depth);
}
}
}
/**
* Drifting clouds effect
*/
function clouds(fb, frame, options = {}) {
const { count = 3, speed = 0.5, depth = 100 } = options;
const cloudChars = ['░', '▒', '▓'];
for (let i = 0; i < count; i++) {
const seed = i * 47;
const baseY = 2 + Math.floor(seededRandom(seed) * (fb.height / 3));
const baseX = seededRandom(seed + 1) * fb.width;
const cloudWidth = 8 + Math.floor(seededRandom(seed + 2) * 12);
const x = Math.floor((baseX + frame * speed) % (fb.width + cloudWidth)) - cloudWidth;
// Draw cloud shape
for (let dx = 0; dx < cloudWidth; dx++) {
const cloudX = x + dx;
if (cloudX >= 0 && cloudX < fb.width) {
// Cloud density varies across width
const density = 1 - Math.abs(dx - cloudWidth / 2) / (cloudWidth / 2);
const charIdx = Math.floor(density * (cloudChars.length - 1));
fb.setPixel(cloudX, baseY, cloudChars[charIdx], depth);
// Add some height variation
if (density > 0.5 && baseY > 0) {
fb.setPixel(cloudX, baseY - 1, cloudChars[0], depth);
}
}
}
}
}
// Make available globally for browser script tag usage
if (typeof globalThis !== 'undefined') {
Object.assign(globalThis, {
stars, starfield, rain, snow, fog, aurora, waves,
gradient, staticNoise, ripples, fireflies, clouds
});
}
})();

View File

@@ -0,0 +1,313 @@
/* eslint-disable */
/**
* Border and Frame Effects
* Decorative borders for framing content
*/
(function() {
// Border character sets
const BORDERS = {
single: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' },
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
heavy: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },
ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' },
dotted: { tl: '·', tr: '·', bl: '·', br: '·', h: '·', v: '·' },
};
// Seeded random for animation consistency
function seededRandom(seed) {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
/**
* Draw a box border
*/
function boxBorder(fb, options = {}) {
const { x = 0, y = 0, width = fb.width, height = fb.height, style = 'single', depth = 0 } = options;
const chars = BORDERS[style] || BORDERS.single;
// Corners
fb.setPixel(x, y, chars.tl, depth);
fb.setPixel(x + width - 1, y, chars.tr, depth);
fb.setPixel(x, y + height - 1, chars.bl, depth);
fb.setPixel(x + width - 1, y + height - 1, chars.br, depth);
// Horizontal lines
for (let dx = 1; dx < width - 1; dx++) {
fb.setPixel(x + dx, y, chars.h, depth);
fb.setPixel(x + dx, y + height - 1, chars.h, depth);
}
// Vertical lines
for (let dy = 1; dy < height - 1; dy++) {
fb.setPixel(x, y + dy, chars.v, depth);
fb.setPixel(x + width - 1, y + dy, chars.v, depth);
}
}
/**
* Draw a fullscreen border with padding
*/
function fullscreenBorder(fb, options = {}) {
const { style = 'single', padding = 1, depth = 0 } = options;
boxBorder(fb, {
x: padding,
y: padding,
width: fb.width - padding * 2,
height: fb.height - padding * 2,
style,
depth
});
}
/**
* Draw only corner decorations
*/
function cornerDecor(fb, options = {}) {
const { style = 'flourish', padding = 1, size = 3, depth = 0 } = options;
const flourishCorners = {
tl: ['╔', '═', '═', '║', ' ', ' ', '║', ' ', ' '],
tr: ['═', '═', '╗', ' ', ' ', '║', ' ', ' ', '║'],
bl: ['║', ' ', ' ', '║', ' ', ' ', '╚', '═', '═'],
br: [' ', ' ', '║', ' ', ' ', '║', '═', '═', '╝'],
};
const simpleCorners = {
tl: ['┌', '─', ' ', '│', ' ', ' ', ' ', ' ', ' '],
tr: [' ', '─', '┐', ' ', ' ', '│', ' ', ' ', ' '],
bl: [' ', ' ', ' ', '│', ' ', ' ', '└', '─', ' '],
br: [' ', ' ', ' ', ' ', ' ', '│', ' ', '─', '┘'],
};
const corners = style === 'flourish' ? flourishCorners : simpleCorners;
// Draw each corner
const positions = [
{ corner: 'tl', x: padding, y: padding },
{ corner: 'tr', x: fb.width - padding - size, y: padding },
{ corner: 'bl', x: padding, y: fb.height - padding - size },
{ corner: 'br', x: fb.width - padding - size, y: fb.height - padding - size },
];
for (const pos of positions) {
const chars = corners[pos.corner];
for (let dy = 0; dy < 3; dy++) {
for (let dx = 0; dx < 3; dx++) {
const char = chars[dy * 3 + dx];
if (char !== ' ') {
fb.setPixel(pos.x + dx, pos.y + dy, char, depth);
}
}
}
}
}
/**
* Marching ants animated border
*/
function marchingAnts(fb, frame, options = {}) {
const { x = 0, y = 0, width = fb.width, height = fb.height, speed = 1, depth = 0 } = options;
const pattern = ['─', '·', '─', '·'];
const offset = Math.floor(frame * speed) % pattern.length;
// Top edge
for (let dx = 0; dx < width; dx++) {
const char = pattern[(dx + offset) % pattern.length];
fb.setPixel(x + dx, y, char, depth);
}
// Bottom edge
for (let dx = 0; dx < width; dx++) {
const char = pattern[(dx - offset + pattern.length * 100) % pattern.length];
fb.setPixel(x + dx, y + height - 1, char, depth);
}
// Left edge
for (let dy = 1; dy < height - 1; dy++) {
const char = pattern[(dy - offset + pattern.length * 100) % pattern.length] === '─' ? '│' : '·';
fb.setPixel(x, y + dy, char, depth);
}
// Right edge
for (let dy = 1; dy < height - 1; dy++) {
const char = pattern[(dy + offset) % pattern.length] === '─' ? '│' : '·';
fb.setPixel(x + width - 1, y + dy, char, depth);
}
}
/**
* Pulsing border effect
*/
function pulseBorder(fb, frame, options = {}) {
const { x = 1, y = 1, width = null, height = null, depth = 0 } = options;
const w = width || fb.width - 2;
const h = height || fb.height - 2;
// Cycle through border styles
const styles = ['dotted', 'single', 'heavy', 'double', 'heavy', 'single'];
const styleIdx = Math.floor(frame / 10) % styles.length;
boxBorder(fb, { x, y, width: w, height: h, style: styles[styleIdx], depth });
}
/**
* Growing border animation
*/
function growBorder(fb, progress, options = {}) {
const { x = 0, y = 0, width = fb.width, height = fb.height, style = 'single', depth = 0 } = options;
const chars = BORDERS[style] || BORDERS.single;
// Calculate how much of the border to draw
const perimeter = 2 * (width + height) - 4;
const drawn = Math.floor(progress * perimeter);
let count = 0;
// Top edge (left to right)
for (let dx = 0; dx < width && count < drawn; dx++, count++) {
const char = dx === 0 ? chars.tl : (dx === width - 1 ? chars.tr : chars.h);
fb.setPixel(x + dx, y, char, depth);
}
// Right edge (top to bottom, excluding top corner)
for (let dy = 1; dy < height && count < drawn; dy++, count++) {
const char = dy === height - 1 ? chars.br : chars.v;
fb.setPixel(x + width - 1, y + dy, char, depth);
}
// Bottom edge (right to left, excluding right corner)
for (let dx = width - 2; dx >= 0 && count < drawn; dx--, count++) {
const char = dx === 0 ? chars.bl : chars.h;
fb.setPixel(x + dx, y + height - 1, char, depth);
}
// Left edge (bottom to top, excluding corners)
for (let dy = height - 2; dy > 0 && count < drawn; dy--, count++) {
fb.setPixel(x, y + dy, chars.v, depth);
}
}
/**
* Draw a border with a title
*/
function framedTitle(fb, y, title, options = {}) {
const { style = 'single', padding = 2, depth = 0 } = options;
const chars = BORDERS[style] || BORDERS.single;
const totalWidth = title.length + padding * 2 + 4; // 4 for border chars and spacing
const x = Math.floor((fb.width - totalWidth) / 2);
// Left side: ┌───
fb.setPixel(x, y, chars.tl, depth);
for (let i = 1; i <= padding; i++) {
fb.setPixel(x + i, y, chars.h, depth);
}
// Space before title
fb.setPixel(x + padding + 1, y, ' ', depth);
// Title
for (let i = 0; i < title.length; i++) {
fb.setPixel(x + padding + 2 + i, y, title[i], depth);
}
// Space after title
fb.setPixel(x + padding + 2 + title.length, y, ' ', depth);
// Right side: ───┐
for (let i = 1; i <= padding; i++) {
fb.setPixel(x + padding + 3 + title.length + i - 1, y, chars.h, depth);
}
fb.setPixel(x + totalWidth - 1, y, chars.tr, depth);
}
/**
* Gradient border using density characters
*/
function gradientBorder(fb, frame, options = {}) {
const { x = 1, y = 1, width = null, height = null, depth = 0 } = options;
const w = width || fb.width - 2;
const h = height || fb.height - 2;
const densityChars = ['.', ':', '-', '=', '+', '*', '#', '%', '@'];
const perimeter = 2 * (w + h) - 4;
let pos = 0;
// Top edge
for (let dx = 0; dx < w; dx++, pos++) {
const charIdx = Math.floor(((pos + frame) % perimeter) / perimeter * densityChars.length);
fb.setPixel(x + dx, y, densityChars[charIdx], depth);
}
// Right edge
for (let dy = 1; dy < h; dy++, pos++) {
const charIdx = Math.floor(((pos + frame) % perimeter) / perimeter * densityChars.length);
fb.setPixel(x + w - 1, y + dy, densityChars[charIdx], depth);
}
// Bottom edge
for (let dx = w - 2; dx >= 0; dx--, pos++) {
const charIdx = Math.floor(((pos + frame) % perimeter) / perimeter * densityChars.length);
fb.setPixel(x + dx, y + h - 1, densityChars[charIdx], depth);
}
// Left edge
for (let dy = h - 2; dy > 0; dy--, pos++) {
const charIdx = Math.floor(((pos + frame) % perimeter) / perimeter * densityChars.length);
fb.setPixel(x, y + dy, densityChars[charIdx], depth);
}
}
/**
* Decorative divider line
*/
function divider(fb, y, options = {}) {
const { style = 'single', padding = 2, depth = 0 } = options;
const chars = BORDERS[style] || BORDERS.single;
for (let x = padding; x < fb.width - padding; x++) {
fb.setPixel(x, y, chars.h, depth);
}
}
/**
* Decorative divider with center text
*/
function dividerWithText(fb, y, text, options = {}) {
const { style = 'single', depth = 0 } = options;
const chars = BORDERS[style] || BORDERS.single;
const textStart = Math.floor((fb.width - text.length) / 2) - 1;
const textEnd = textStart + text.length + 1;
// Left line
for (let x = 2; x < textStart; x++) {
fb.setPixel(x, y, chars.h, depth);
}
// Text with spacing
fb.setPixel(textStart, y, ' ', depth);
for (let i = 0; i < text.length; i++) {
fb.setPixel(textStart + 1 + i, y, text[i], depth);
}
fb.setPixel(textEnd, y, ' ', depth);
// Right line
for (let x = textEnd + 1; x < fb.width - 2; x++) {
fb.setPixel(x, y, chars.h, depth);
}
}
// Make available globally for browser script tag usage
if (typeof globalThis !== 'undefined') {
Object.assign(globalThis, {
BORDERS, boxBorder, fullscreenBorder, cornerDecor,
marchingAnts, pulseBorder, growBorder, framedTitle,
gradientBorder, divider, dividerWithText
});
}
})();

View File

@@ -0,0 +1,66 @@
/* eslint-disable */
/**
* Thinkback Animation Helpers
* Re-exports all helper modules for easy importing
*/
// Import files to execute them and populate globalThis
import './transitions.js';
import './backgrounds.js';
import './text_effects.js';
import './particles.js';
import './borders.js';
import './scene_system.js';
import './news_effects.js';
import './awards_effects.js';
import './rpg_effects.js';
// Re-export from globalThis for ES module consumers
export const {
// transitions
wipeLeft, wipeRight, wipeDown, wipeUp,
circleReveal, circleClose, irisIn, irisOut,
blindsH, blindsV, checkerboard, diagonalWipe,
dissolve, pixelate, matrixRain, getSlideOffset, fade,
supernova, spiral, shatter, tornado,
// backgrounds
stars, starfield, rain, snow, fog, aurora, waves,
gradient, staticNoise, ripples, fireflies, clouds,
// text_effects
typewriter, fadeByLetter, wave, bounce, shake, float,
drawTypewriter, drawTypewriterCentered, drawWaveText,
drawGlitchText, drawGlitchTextCentered, drawScatterText,
slideIn, slideOut, drawZoomText, drawFadeInText,
drawRainbowText, drawWipeReveal,
// Claude branding
CLAUDE_MASCOT, CLAUDE_MASCOT_WIDTH, drawClaudeMascot,
CLAUDE_CODE_LOGO, CLAUDE_CODE_LOGO_WIDTH, CLAUDE_CODE_LOGO_HEIGHT,
CLAUDE_ORANGE, drawClaudeCodeLogo, drawThinkbackIntro,
// particles
confetti, sparkles, burst, bubbles, hearts, musicNotes,
leaves, embers, dust, floatingParticles, trail, orbit,
shootingStars, glitter,
// borders
BORDERS, boxBorder, fullscreenBorder, cornerDecor,
marchingAnts, pulseBorder, growBorder, framedTitle,
gradientBorder, divider, dividerWithText,
// scene system
SceneManager, getScenePhase, renderScene, createScene,
staggeredReveal, easeInOut, easeOut, animateCounter,
ANIMATION_FPS, DEFAULT_HOLD_SECONDS,
DEFAULT_TRANSITION_IN_SECONDS, DEFAULT_TRANSITION_OUT_SECONDS,
// news effects
lowerThird, tickerTape, breakingBanner, liveIndicator,
segmentTitle, statCounter, forecastBar, splitWipe,
pushTransition, headlineCrawl, countdownReveal,
newsArticle, newsGrid, headlineCarousel, accomplishmentSpotlight, newsFeed,
// awards effects
trophyDisplay, awardBadge, envelopeReveal, categoryTitle,
acceptanceSpeech, nomineeCard, winnerAnnouncement, applauseMeter,
standingOvation, redCarpetBorder, spotlightText, spotlightReveal,
curtainReveal, awardsStatue,
// rpg effects
characterSprite, titleScreen, textBox, classSelect,
questCard, questBanner, xpBar, levelUp,
statsPanel, bossHealth, victoryFanfare, creditsRoll, inventorySlot,
} = globalThis;

View File

@@ -0,0 +1,970 @@
/* eslint-disable */
/**
* News Broadcast Effects
* Specialized effects for the morning news vibe
*/
(function() {
// Seeded random for consistent effects
function seededRandom(seed) {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
// Easing functions
function easeOut(t) {
return 1 - Math.pow(1 - t, 3);
}
function easeInOut(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// ============================================================================
// LOWER THIRD - News-style stat display bar
// ============================================================================
/**
* Draw a "lower third" graphics bar (news-style info bar)
* @param fb - Framebuffer
* @param y - Y position (typically near bottom)
* @param label - Left side label (e.g., "COMMITS THIS YEAR")
* @param value - Right side value (e.g., "1,247")
* @param progress - Animation progress 0-1 (for reveal)
* @param options - { style: 'single'|'double'|'heavy', depth: number }
*/
function lowerThird(fb, y, label, value, progress, options = {}) {
const { style = 'heavy', depth = 5, accentChar = '▌' } = options;
const totalWidth = Math.min(fb.width - 4, 50);
const x = Math.floor((fb.width - totalWidth) / 2);
// Animate width based on progress
const visibleWidth = Math.floor(easeOut(progress) * totalWidth);
if (visibleWidth < 3) return;
const BORDERS = {
single: { h: '─', v: '│', tl: '┌', tr: '┐', bl: '└', br: '┘' },
double: { h: '═', v: '║', tl: '╔', tr: '╗', bl: '╚', br: '╝' },
heavy: { h: '━', v: '┃', tl: '┏', tr: '┓', bl: '┗', br: '┛' },
};
const chars = BORDERS[style] || BORDERS.heavy;
const startX = Math.floor((fb.width - visibleWidth) / 2);
// Top border
fb.setPixel(startX, y, chars.tl, depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(startX + i, y, chars.h, depth);
}
fb.setPixel(startX + visibleWidth - 1, y, chars.tr, depth);
// Content line with accent bar
fb.setPixel(startX, y + 1, chars.v, depth);
fb.setPixel(startX + 1, y + 1, accentChar, depth);
fb.setPixel(startX + 2, y + 1, ' ', depth);
// Draw label (left-aligned after accent)
const maxLabelLen = visibleWidth - value.length - 6;
const displayLabel = label.slice(0, Math.max(0, maxLabelLen));
for (let i = 0; i < displayLabel.length; i++) {
fb.setPixel(startX + 3 + i, y + 1, displayLabel[i], depth);
}
// Draw value (right-aligned)
const valueStart = startX + visibleWidth - value.length - 2;
for (let i = 0; i < value.length; i++) {
if (valueStart + i > startX + 3 + displayLabel.length) {
fb.setPixel(valueStart + i, y + 1, value[i], depth);
}
}
fb.setPixel(startX + visibleWidth - 1, y + 1, chars.v, depth);
// Bottom border
fb.setPixel(startX, y + 2, chars.bl, depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(startX + i, y + 2, chars.h, depth);
}
fb.setPixel(startX + visibleWidth - 1, y + 2, chars.br, depth);
}
// ============================================================================
// TICKER TAPE - Scrolling news ticker
// ============================================================================
/**
* Draw a scrolling ticker tape at bottom of screen
* @param fb - Framebuffer
* @param y - Y position
* @param items - Array of strings to scroll
* @param frame - Current frame for animation
* @param options - { separator: string, speed: number, depth: number }
*/
function tickerTape(fb, y, items, frame, options = {}) {
const { separator = ' ▸ ', speed = 0.5, depth = 5 } = options;
// Build full ticker string
const tickerText = items.join(separator) + separator;
const doubledText = tickerText + tickerText; // Double for seamless loop
// Calculate scroll offset
const offset = Math.floor(frame * speed) % tickerText.length;
// Draw ticker background line
for (let x = 0; x < fb.width; x++) {
const charIdx = (x + offset) % doubledText.length;
fb.setPixel(x, y, doubledText[charIdx], depth);
}
}
// ============================================================================
// BREAKING NEWS BANNER
// ============================================================================
/**
* Draw a breaking news banner with optional flash effect
* @param fb - Framebuffer
* @param y - Y position
* @param text - Breaking news text
* @param frame - Current frame for flash effect
* @param options - { flash: boolean, depth: number }
*/
function breakingBanner(fb, y, text, frame, options = {}) {
const { flash = true, depth = 10 } = options;
const totalWidth = text.length + 8;
const x = Math.floor((fb.width - totalWidth) / 2);
// Flash effect - alternate between filled and empty style
const isFlash = flash && Math.floor(frame / 8) % 2 === 0;
// Top border
fb.setPixel(x, y, '╔', depth);
for (let i = 1; i < totalWidth - 1; i++) {
fb.setPixel(x + i, y, '═', depth);
}
fb.setPixel(x + totalWidth - 1, y, '╗', depth);
// Content line
fb.setPixel(x, y + 1, '║', depth);
// Flashing indicator
const indicator = isFlash ? ' ⚡ ' : ' ';
for (let i = 0; i < 3; i++) {
fb.setPixel(x + 1 + i, y + 1, indicator[i], depth);
}
// Text
for (let i = 0; i < text.length; i++) {
fb.setPixel(x + 4 + i, y + 1, text[i], depth);
}
// Flashing indicator (end)
for (let i = 0; i < 3; i++) {
fb.setPixel(x + 4 + text.length + i, y + 1, indicator[2 - i], depth);
}
fb.setPixel(x + totalWidth - 1, y + 1, '║', depth);
// Bottom border
fb.setPixel(x, y + 2, '╚', depth);
for (let i = 1; i < totalWidth - 1; i++) {
fb.setPixel(x + i, y + 2, '═', depth);
}
fb.setPixel(x + totalWidth - 1, y + 2, '╝', depth);
}
// ============================================================================
// LIVE INDICATOR - Blinking "LIVE" badge
// ============================================================================
/**
* Draw a blinking LIVE indicator
* @param fb - Framebuffer
* @param x - X position
* @param y - Y position
* @param frame - Current frame for blink effect
* @param options - { speed: number, depth: number }
*/
function liveIndicator(fb, x, y, frame, options = {}) {
const { speed = 0.3, depth = 10 } = options;
// Blink the dot
const showDot = Math.sin(frame * speed) > 0;
const dot = showDot ? '●' : '○';
const text = `${dot} LIVE`;
for (let i = 0; i < text.length; i++) {
fb.setPixel(x + i, y, text[i], depth);
}
}
// ============================================================================
// SEGMENT TITLE - News segment header
// ============================================================================
/**
* Draw a segment title with decorative elements
* @param fb - Framebuffer
* @param y - Y position
* @param title - Segment title (e.g., "TOP STORIES")
* @param progress - Animation progress 0-1
* @param options - { style: 'bracket'|'arrow'|'box', depth: number }
*/
function segmentTitle(fb, y, title, progress, options = {}) {
const { style = 'arrow', depth = 5 } = options;
const easedProgress = easeOut(progress);
const visibleChars = Math.floor(easedProgress * title.length);
const visibleTitle = title.slice(0, visibleChars);
let fullText;
switch (style) {
case 'bracket':
fullText = `${visibleTitle}`;
break;
case 'box':
fullText = `${visibleTitle}`;
break;
case 'arrow':
default:
fullText = `▸▸▸ ${visibleTitle}`;
}
const x = Math.floor((fb.width - fullText.length) / 2);
for (let i = 0; i < fullText.length; i++) {
fb.setPixel(x + i, y, fullText[i], depth);
}
}
// ============================================================================
// STAT COUNTER - Animated number reveal
// ============================================================================
/**
* Animate a number counting up
* @param fb - Framebuffer
* @param x - X position
* @param y - Y position
* @param targetValue - Final number to display
* @param progress - Animation progress 0-1
* @param options - { prefix: string, suffix: string, depth: number }
*/
function statCounter(fb, x, y, targetValue, progress, options = {}) {
const { prefix = '', suffix = '', depth = 5, commas = true } = options;
const easedProgress = easeOut(progress);
const currentValue = Math.floor(easedProgress * targetValue);
// Format with commas if requested
let valueStr = currentValue.toString();
if (commas && currentValue >= 1000) {
valueStr = currentValue.toLocaleString();
}
const fullText = `${prefix}${valueStr}${suffix}`;
for (let i = 0; i < fullText.length; i++) {
fb.setPixel(x + i, y, fullText[i], depth);
}
}
// ============================================================================
// WEATHER FORECAST STYLE - Bar chart display
// ============================================================================
/**
* Draw a horizontal bar chart (weather forecast style)
* @param fb - Framebuffer
* @param x - X position
* @param y - Y position
* @param label - Label for this bar
* @param value - Value (0-1 for percentage)
* @param maxWidth - Maximum width of bar
* @param progress - Animation progress 0-1
* @param options - { char: string, depth: number }
*/
function forecastBar(fb, x, y, label, value, maxWidth, progress, options = {}) {
const { char = '█', emptyChar = '░', depth = 5 } = options;
const labelWidth = 12;
const barWidth = maxWidth - labelWidth - 2;
// Draw label (right-aligned in label area)
const paddedLabel = label.slice(0, labelWidth).padStart(labelWidth);
for (let i = 0; i < paddedLabel.length; i++) {
fb.setPixel(x + i, y, paddedLabel[i], depth);
}
// Separator
fb.setPixel(x + labelWidth, y, ' ', depth);
// Draw bar
const animatedValue = value * easeOut(progress);
const filledWidth = Math.floor(animatedValue * barWidth);
for (let i = 0; i < barWidth; i++) {
const barChar = i < filledWidth ? char : emptyChar;
fb.setPixel(x + labelWidth + 1 + i, y, barChar, depth);
}
}
// ============================================================================
// SPLIT WIPE TRANSITION - News-style double wipe
// ============================================================================
/**
* Split wipe from center outward (news transition style)
* @param fb - Framebuffer
* @param progress - Transition progress 0-1
*/
function splitWipe(fb, progress) {
const centerY = Math.floor(fb.height / 2);
const halfHeight = fb.height / 2;
const wipeDistance = Math.floor(easeOut(progress) * halfHeight);
// Wipe from center upward
for (let y = centerY - wipeDistance; y >= 0; y--) {
for (let x = 0; x < fb.width; x++) {
if (centerY - y > wipeDistance) {
fb.setPixel(x, y, ' ', -100);
}
}
}
// Wipe from center downward
for (let y = centerY + wipeDistance; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
if (y - centerY > wipeDistance) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
// ============================================================================
// PUSH TRANSITION - Content pushes off screen
// ============================================================================
/**
* Push transition - old content slides out as new slides in
* @param fb - Framebuffer
* @param progress - Transition progress 0-1
* @param direction - 'left', 'right', 'up', 'down'
*/
function pushTransition(fb, progress, direction = 'left') {
const easedProgress = easeOut(progress);
if (direction === 'left' || direction === 'right') {
const offset = Math.floor(easedProgress * fb.width);
const dir = direction === 'left' ? 1 : -1;
// Clear the area being pushed into
for (let y = 0; y < fb.height; y++) {
for (let i = 0; i < offset; i++) {
const x = direction === 'left' ? fb.width - 1 - i : i;
fb.setPixel(x, y, ' ', -100);
}
}
} else {
const offset = Math.floor(easedProgress * fb.height);
for (let x = 0; x < fb.width; x++) {
for (let i = 0; i < offset; i++) {
const y = direction === 'up' ? fb.height - 1 - i : i;
fb.setPixel(x, y, ' ', -100);
}
}
}
}
// ============================================================================
// HEADLINE CRAWL - Typewriter with cursor flash
// ============================================================================
/**
* Draw headline with news-style typewriter and blinking cursor
* @param fb - Framebuffer
* @param y - Y position
* @param text - Headline text
* @param progress - Animation progress 0-1
* @param frame - Current frame for cursor blink
* @param options - { depth: number }
*/
function headlineCrawl(fb, y, text, progress, frame, options = {}) {
const { depth = 5, centered = true } = options;
const visibleChars = Math.floor(progress * text.length);
const visibleText = text.slice(0, visibleChars);
const x = centered
? Math.floor((fb.width - text.length) / 2)
: 2;
// Draw visible text
for (let i = 0; i < visibleText.length; i++) {
fb.setPixel(x + i, y, visibleText[i], depth);
}
// Blinking block cursor at end
if (progress < 1) {
const cursorChar = Math.floor(frame / 6) % 2 === 0 ? '█' : ' ';
fb.setPixel(x + visibleChars, y, cursorChar, depth);
}
}
// ============================================================================
// COUNTDOWN REVEAL - "3... 2... 1..." style countdown
// ============================================================================
/**
* Draw a dramatic countdown
* @param fb - Framebuffer
* @param progress - Animation progress 0-1
* @param options - { numbers: array, depth: number }
*/
function countdownReveal(fb, progress, options = {}) {
const { numbers = ['3', '2', '1', 'GO!'], depth = 10 } = options;
const numIdx = Math.floor(progress * numbers.length);
if (numIdx >= numbers.length) return;
const current = numbers[numIdx];
const phaseProgress = (progress * numbers.length) % 1;
// Scale effect
const scale = 1 + (1 - phaseProgress) * 0.5;
const x = Math.floor((fb.width - current.length) / 2);
const y = Math.floor(fb.height / 2);
for (let i = 0; i < current.length; i++) {
fb.setPixel(x + i, y, current[i], depth);
}
}
// ============================================================================
// NEWS ARTICLE - Full article card display
// ============================================================================
/**
* Draw a news article card (like a newspaper article)
* @param fb - Framebuffer
* @param x - X position (left edge)
* @param y - Y position (top edge)
* @param article - { headline, subhead?, body?, stat?, statLabel?, category? }
* @param progress - Animation progress 0-1
* @param options - { width, style, depth }
*/
function newsArticle(fb, x, y, article, progress, options = {}) {
const {
width = 40,
style = 'boxed', // 'boxed', 'minimal', 'breaking'
depth = 5,
} = options;
const {
headline = '',
subhead = '',
body = '',
stat = '',
statLabel = '',
category = '',
} = article;
const easedProgress = easeOut(progress);
if (easedProgress < 0.05) return;
let currentY = y;
// Calculate visible width for animation
const visibleWidth = Math.floor(easedProgress * width);
if (visibleWidth < 5) return;
// Draw box border if boxed style
if (style === 'boxed' || style === 'breaking') {
const borderChar = style === 'breaking' ? '═' : '─';
const cornerTL = style === 'breaking' ? '╔' : '┌';
const cornerTR = style === 'breaking' ? '╗' : '┐';
// Top border
fb.setPixel(x, currentY, cornerTL, depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(x + i, currentY, borderChar, depth);
}
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, cornerTR, depth);
currentY++;
}
// Category tag
if (category && easedProgress > 0.2) {
const catText = ` ${category} `;
const catStart = x + 2;
for (let i = 0; i < catText.length && catStart + i < x + visibleWidth - 1; i++) {
fb.setPixel(catStart + i, currentY, catText[i], depth + 1);
}
currentY++;
}
// Headline
if (headline && easedProgress > 0.3) {
const headlineProgress = Math.min(1, (easedProgress - 0.3) / 0.3);
const visibleHeadline = headline.slice(0, Math.floor(headlineProgress * headline.length));
const headStart = x + 2;
for (let i = 0; i < visibleHeadline.length && headStart + i < x + visibleWidth - 2; i++) {
fb.setPixel(headStart + i, currentY, visibleHeadline[i], depth + 2);
}
currentY++;
}
// Subhead
if (subhead && easedProgress > 0.5) {
const subStart = x + 2;
const displaySub = subhead.slice(0, visibleWidth - 4);
for (let i = 0; i < displaySub.length; i++) {
fb.setPixel(subStart + i, currentY, displaySub[i], depth);
}
currentY++;
}
// Divider line
if ((body || stat) && easedProgress > 0.55) {
for (let i = 2; i < Math.min(visibleWidth - 2, 15); i++) {
fb.setPixel(x + i, currentY, '·', depth);
}
currentY++;
}
// Body text (wrap if needed)
if (body && easedProgress > 0.6) {
const bodyProgress = Math.min(1, (easedProgress - 0.6) / 0.3);
const maxBodyWidth = visibleWidth - 4;
const words = body.split(' ');
let line = '';
let lineCount = 0;
const maxLines = 3;
for (const word of words) {
if ((line + ' ' + word).length > maxBodyWidth) {
// Draw current line
const visibleLine = line.slice(0, Math.floor(bodyProgress * line.length));
for (let i = 0; i < visibleLine.length; i++) {
fb.setPixel(x + 2 + i, currentY, visibleLine[i], depth);
}
currentY++;
lineCount++;
if (lineCount >= maxLines) break;
line = word;
} else {
line = line ? line + ' ' + word : word;
}
}
// Draw remaining line
if (line && lineCount < maxLines) {
const visibleLine = line.slice(0, Math.floor(bodyProgress * line.length));
for (let i = 0; i < visibleLine.length; i++) {
fb.setPixel(x + 2 + i, currentY, visibleLine[i], depth);
}
currentY++;
}
}
// Big stat display
if (stat && easedProgress > 0.7) {
const statProgress = Math.min(1, (easedProgress - 0.7) / 0.2);
const statStr = typeof stat === 'number'
? Math.floor(statProgress * stat).toLocaleString()
: stat;
const statStart = x + 2;
for (let i = 0; i < statStr.length && statStart + i < x + visibleWidth - 2; i++) {
fb.setPixel(statStart + i, currentY, statStr[i], depth + 3);
}
currentY++;
if (statLabel) {
for (let i = 0; i < statLabel.length && statStart + i < x + visibleWidth - 2; i++) {
fb.setPixel(statStart + i, currentY, statLabel[i], depth);
}
currentY++;
}
}
// Bottom border
if (style === 'boxed' || style === 'breaking') {
const borderChar = style === 'breaking' ? '═' : '─';
const cornerBL = style === 'breaking' ? '╚' : '└';
const cornerBR = style === 'breaking' ? '╝' : '┘';
fb.setPixel(x, currentY, cornerBL, depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(x + i, currentY, borderChar, depth);
}
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, cornerBR, depth);
}
return currentY - y + 1; // Return height
}
// ============================================================================
// NEWS GRID - Multiple articles in a grid layout
// ============================================================================
/**
* Draw multiple news articles in a grid
* @param fb - Framebuffer
* @param articles - Array of article objects
* @param progress - Overall animation progress 0-1
* @param options - { columns, startY, spacing, articleWidth, staggerDelay }
*/
function newsGrid(fb, articles, progress, options = {}) {
const {
columns = 2,
startY = 4,
startX = 2,
spacing = 2,
articleWidth = 35,
staggerDelay = 0.15,
style = 'boxed',
} = options;
let currentY = startY;
let maxHeightInRow = 0;
articles.forEach((article, idx) => {
const col = idx % columns;
const row = Math.floor(idx / columns);
// Calculate staggered progress for this article
const articleProgress = Math.max(0, Math.min(1,
(progress - idx * staggerDelay) / (1 - (articles.length - 1) * staggerDelay)
));
if (articleProgress <= 0) return;
const x = startX + col * (articleWidth + spacing);
// Reset Y for new row
if (col === 0 && row > 0) {
currentY += maxHeightInRow + spacing;
maxHeightInRow = 0;
}
const height = newsArticle(fb, x, currentY, article, articleProgress, {
width: articleWidth,
style,
});
maxHeightInRow = Math.max(maxHeightInRow, height || 0);
});
}
// ============================================================================
// HEADLINE CAROUSEL - Rotating headlines
// ============================================================================
/**
* Show headlines one at a time with transitions
* @param fb - Framebuffer
* @param headlines - Array of headline strings or article objects
* @param progress - Animation progress 0-1 (cycles through all headlines)
* @param frame - Current frame for effects
* @param options - { y, style }
*/
function headlineCarousel(fb, headlines, progress, frame, options = {}) {
const { y = 10, style = 'crawl', depth = 5 } = options;
const numHeadlines = headlines.length;
const headlineIdx = Math.floor(progress * numHeadlines) % numHeadlines;
const headlineProgress = (progress * numHeadlines) % 1;
const headline = headlines[headlineIdx];
const text = typeof headline === 'string' ? headline : headline.headline;
if (style === 'crawl') {
headlineCrawl(fb, y, text, headlineProgress, frame, { depth });
} else if (style === 'slide') {
if (headlineProgress < 0.2) {
// Slide in
const slideP = headlineProgress / 0.2;
slideIn(fb, y, text, slideP, { from: 'right' });
} else if (headlineProgress > 0.8) {
// Slide out
const slideP = (headlineProgress - 0.8) / 0.2;
slideOut(fb, y, text, slideP, { to: 'left' });
} else {
// Static
fb.drawCenteredText(y, text);
}
} else {
// Fade style
const opacity = headlineProgress < 0.2 ? headlineProgress / 0.2
: headlineProgress > 0.8 ? 1 - (headlineProgress - 0.8) / 0.2
: 1;
if (opacity > 0.5) {
fb.drawCenteredText(y, text);
}
}
}
// Helper for slideIn/slideOut (if not already available)
function slideIn(fb, y, text, progress, options = {}) {
const { from = 'left', depth = 5 } = options;
const x = from === 'left'
? Math.floor(-text.length + easeOut(progress) * (fb.width / 2 + text.length / 2))
: Math.floor(fb.width - easeOut(progress) * (fb.width / 2 + text.length / 2));
for (let i = 0; i < text.length; i++) {
if (x + i >= 0 && x + i < fb.width) {
fb.setPixel(x + i, y, text[i], depth);
}
}
}
function slideOut(fb, y, text, progress, options = {}) {
const { to = 'left', depth = 5 } = options;
const centerX = Math.floor((fb.width - text.length) / 2);
const offset = Math.floor(easeOut(progress) * (fb.width / 2 + text.length));
const x = to === 'left' ? centerX - offset : centerX + offset;
for (let i = 0; i < text.length; i++) {
if (x + i >= 0 && x + i < fb.width) {
fb.setPixel(x + i, y, text[i], depth);
}
}
}
// ============================================================================
// PROJECT SPOTLIGHT - Dedicated project article display
// ============================================================================
/**
* Draw a featured project as a full news article
* @param fb - Framebuffer
* @param project - { name, commits, description?, body?, rank? }
* @param progress - Animation progress 0-1
* @param frame - Current frame for effects
* @param options - { centered, width }
*/
function accomplishmentSpotlight(fb, project, progress, frame, options = {}) {
const {
centered = true,
width = 50,
y = 5,
depth = 5,
showRank = true,
} = options;
const { name, commits, description, body, rank } = project;
const x = centered ? Math.floor((fb.width - width) / 2) : 2;
const easedProgress = easeOut(progress);
if (easedProgress < 0.1) return;
// Box border with double lines for emphasis
const visibleWidth = Math.floor(easedProgress * width);
let currentY = y;
// Helper to draw a row with side borders
function drawRow(text, textDepth = depth) {
fb.setPixel(x, currentY, '║', depth);
for (let i = 0; i < text.length && i < visibleWidth - 2; i++) {
fb.setPixel(x + 1 + i, currentY, text[i], textDepth);
}
// Fill remaining space
for (let i = text.length; i < visibleWidth - 2; i++) {
fb.setPixel(x + 1 + i, currentY, ' ', depth);
}
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '║', depth);
currentY++;
}
// Top border
fb.setPixel(x, currentY, '╔', depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(x + i, currentY, '═', depth);
}
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '╗', depth);
currentY++;
// Rank badge
if (showRank && rank && easedProgress > 0.15) {
drawRow(` #${rank} PROJECT`, depth + 1);
}
// Project name
if (easedProgress > 0.25) {
const nameProgress = Math.min(1, (easedProgress - 0.25) / 0.15);
const visibleName = name.slice(0, Math.floor(nameProgress * name.length));
drawRow(` ${visibleName}`, depth + 2);
}
// Divider after name
if (easedProgress > 0.35) {
fb.setPixel(x, currentY, '╟', depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(x + i, currentY, '─', depth);
}
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '╢', depth);
currentY++;
}
// Commits stat with animated counter
if (easedProgress > 0.4) {
const counterProgress = Math.min(1, (easedProgress - 0.4) / 0.15);
const displayCommits = Math.floor(counterProgress * commits);
drawRow(` ${displayCommits.toLocaleString()} COMMITS`, depth + 3);
}
// Description (short tagline)
if (description && easedProgress > 0.5) {
const descProgress = Math.min(1, (easedProgress - 0.5) / 0.1);
const maxDesc = visibleWidth - 4;
const displayDesc = description.slice(0, Math.min(maxDesc, Math.floor(descProgress * description.length)));
drawRow(` ${displayDesc}`, depth);
}
// Body text (longer description with word wrap)
if (body && easedProgress > 0.55) {
// Add a small divider
fb.setPixel(x, currentY, '║', depth);
for (let i = 1; i < Math.min(visibleWidth - 1, 20); i++) {
fb.setPixel(x + i, currentY, '·', depth);
}
for (let i = 20; i < visibleWidth - 1; i++) {
fb.setPixel(x + i, currentY, ' ', depth);
}
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '║', depth);
currentY++;
// Word-wrap the body text
const bodyProgress = Math.min(1, (easedProgress - 0.55) / 0.25);
const maxLineWidth = visibleWidth - 6;
const words = body.split(' ');
const lines = [];
let currentLine = '';
for (const word of words) {
if ((currentLine + ' ' + word).trim().length > maxLineWidth) {
if (currentLine) lines.push(currentLine);
currentLine = word;
} else {
currentLine = currentLine ? currentLine + ' ' + word : word;
}
}
if (currentLine) lines.push(currentLine);
// Draw visible portion of body
const totalChars = lines.join('').length;
let charsDrawn = 0;
const charsToShow = Math.floor(bodyProgress * totalChars);
for (const line of lines) {
if (charsDrawn >= charsToShow) break;
const lineCharsToShow = Math.min(line.length, charsToShow - charsDrawn);
const visibleLine = line.slice(0, lineCharsToShow);
drawRow(` ${visibleLine}`, depth);
charsDrawn += line.length;
}
}
// Bottom border
if (easedProgress > 0.85) {
fb.setPixel(x, currentY, '╚', depth);
for (let i = 1; i < visibleWidth - 1; i++) {
fb.setPixel(x + i, currentY, '═', depth);
}
if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '╝', depth);
}
}
// ============================================================================
// SCROLLING NEWS FEED - Vertical scrolling headlines
// ============================================================================
/**
* Draw a vertically scrolling news feed
* @param fb - Framebuffer
* @param items - Array of { headline, category?, time? }
* @param frame - Current frame for scroll animation
* @param options - { x, y, width, height, speed }
*/
function newsFeed(fb, items, frame, options = {}) {
const {
x = 2,
y = 4,
width = 40,
height = 15,
speed = 0.1,
depth = 5,
} = options;
const lineHeight = 3;
const totalHeight = items.length * lineHeight;
const scrollOffset = (frame * speed) % totalHeight;
// Draw border
fb.setPixel(x, y - 1, '┌', depth);
for (let i = 1; i < width - 1; i++) {
fb.setPixel(x + i, y - 1, '─', depth);
}
fb.setPixel(x + width - 1, y - 1, '┐', depth);
items.forEach((item, idx) => {
const itemY = y + idx * lineHeight - Math.floor(scrollOffset);
// Only draw if within visible area
if (itemY >= y && itemY < y + height) {
// Category/time
if (item.category) {
const catText = `[${item.category}]`;
for (let i = 0; i < catText.length && x + 1 + i < x + width - 1; i++) {
fb.setPixel(x + 1 + i, itemY, catText[i], depth);
}
}
// Headline
const headY = itemY + 1;
if (headY < y + height) {
const maxLen = width - 3;
const headline = item.headline.slice(0, maxLen);
for (let i = 0; i < headline.length; i++) {
fb.setPixel(x + 2 + i, headY, headline[i], depth + 1);
}
}
}
});
// Bottom border
fb.setPixel(x, y + height, '└', depth);
for (let i = 1; i < width - 1; i++) {
fb.setPixel(x + i, y + height, '─', depth);
}
fb.setPixel(x + width - 1, y + height, '┘', depth);
}
// Make available globally for browser script tag usage
if (typeof globalThis !== 'undefined') {
Object.assign(globalThis, {
lowerThird,
tickerTape,
breakingBanner,
liveIndicator,
segmentTitle,
statCounter,
forecastBar,
splitWipe,
pushTransition,
headlineCrawl,
countdownReveal,
// New article helpers
newsArticle,
newsGrid,
headlineCarousel,
accomplishmentSpotlight,
newsFeed,
});
}
})();

View File

@@ -0,0 +1,349 @@
/* eslint-disable */
/**
* Particle System Effects
* Various particle effects for celebrations, atmosphere, etc.
* All functions take (fb, frame, options)
*/
(function() {
// Seeded random for consistent patterns
function seededRandom(seed) {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
/**
* Confetti celebration effect
*/
function confetti(fb, frame, options = {}) {
const { count = 20, chars = ['■', '◆', '●', '▲', '★'], depth = 50, speed = 1 } = options;
for (let i = 0; i < count; i++) {
const seed = i * 31;
// Random starting position
const startX = seededRandom(seed) * fb.width;
const startY = -seededRandom(seed + 1) * fb.height;
// Fall with some horizontal drift
const fallSpeed = 0.3 + seededRandom(seed + 2) * 0.5 * speed;
const drift = Math.sin((frame + seed) * 0.1) * 2;
const y = (startY + frame * fallSpeed) % (fb.height + 10);
const x = Math.floor((startX + drift + fb.width) % fb.width);
if (y >= 0 && y < fb.height) {
const charIdx = Math.floor(seededRandom(seed + 3) * chars.length);
fb.setPixel(x, Math.floor(y), chars[charIdx], depth);
}
}
}
/**
* Sparkle effect - random twinkling lights
*/
function sparkles(fb, frame, options = {}) {
const { density = 0.005, chars = ['✦', '*', '·', '+'], depth = 50 } = options;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const seed = x * 31 + y * 17;
// Sparkle appears and disappears
const sparklePhase = (frame + seed) % 20;
if (sparklePhase < 5 && seededRandom(seed) < density) {
const charIdx = Math.floor(sparklePhase / 5 * chars.length);
fb.setPixel(x, y, chars[charIdx], depth);
}
}
}
}
/**
* Burst effect - particles exploding from a point
*/
function burst(fb, frame, options = {}) {
const { cx = null, cy = null, count = 12, startFrame = 0, char = '*', depth = 50, duration = 30 } = options;
const centerX = cx !== null ? cx : fb.width / 2;
const centerY = cy !== null ? cy : fb.height / 2;
const elapsed = frame - startFrame;
if (elapsed < 0 || elapsed > duration) return;
const progress = elapsed / duration;
const radius = progress * Math.max(fb.width, fb.height) / 2;
const fade = 1 - progress;
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2;
const x = Math.floor(centerX + Math.cos(angle) * radius * 2.16); // Aspect ratio
const y = Math.floor(centerY + Math.sin(angle) * radius);
if (x >= 0 && x < fb.width && y >= 0 && y < fb.height && fade > 0.3) {
fb.setPixel(x, y, char, depth);
}
}
}
/**
* Bubbles rising effect
*/
function bubbles(fb, frame, options = {}) {
const { count = 10, chars = ['○', '◯', 'O', 'o'], depth = 50, speed = 1 } = options;
for (let i = 0; i < count; i++) {
const seed = i * 47;
const baseX = seededRandom(seed) * fb.width;
const riseSpeed = 0.2 + seededRandom(seed + 1) * 0.3 * speed;
// Bubbles rise from bottom
const y = fb.height - (frame * riseSpeed + seededRandom(seed + 2) * fb.height) % (fb.height + 5);
// Slight horizontal wobble
const wobble = Math.sin((frame + seed) * 0.15) * 1.5;
const x = Math.floor((baseX + wobble + fb.width) % fb.width);
if (y >= 0 && y < fb.height) {
const charIdx = Math.floor(seededRandom(seed + 3) * chars.length);
fb.setPixel(x, Math.floor(y), chars[charIdx], depth);
}
}
}
/**
* Floating hearts
*/
function hearts(fb, frame, options = {}) {
const { count = 6, char = '♥', depth = 50, speed = 1 } = options;
for (let i = 0; i < count; i++) {
const seed = i * 23;
const baseX = seededRandom(seed) * fb.width;
const floatSpeed = 0.15 + seededRandom(seed + 1) * 0.2 * speed;
// Hearts rise gently
const y = fb.height - (frame * floatSpeed + seededRandom(seed + 2) * fb.height) % (fb.height + 5);
// Gentle sway
const sway = Math.sin((frame + seed) * 0.08) * 2;
const x = Math.floor((baseX + sway + fb.width) % fb.width);
if (y >= 0 && y < fb.height) {
fb.setPixel(x, Math.floor(y), char, depth);
}
}
}
/**
* Music notes floating
*/
function musicNotes(fb, frame, options = {}) {
const { count = 8, chars = ['♪', '♫', '♬', '♩'], depth = 50, speed = 1 } = options;
for (let i = 0; i < count; i++) {
const seed = i * 37;
const baseX = seededRandom(seed) * fb.width;
const floatSpeed = 0.2 + seededRandom(seed + 1) * 0.3 * speed;
// Notes rise and sway
const y = fb.height - (frame * floatSpeed + seededRandom(seed + 2) * fb.height) % (fb.height + 5);
const sway = Math.sin((frame + seed) * 0.1) * 3;
const x = Math.floor((baseX + sway + fb.width) % fb.width);
if (y >= 0 && y < fb.height) {
const charIdx = Math.floor(seededRandom(seed + 3) * chars.length);
fb.setPixel(x, Math.floor(y), chars[charIdx], depth);
}
}
}
/**
* Falling leaves
*/
function leaves(fb, frame, options = {}) {
const { count = 8, chars = ['*', '✿', '❀', '◇'], depth = 50, speed = 1 } = options;
for (let i = 0; i < count; i++) {
const seed = i * 29;
const baseX = seededRandom(seed) * fb.width;
const fallSpeed = 0.15 + seededRandom(seed + 1) * 0.2 * speed;
// Leaves fall slowly with horizontal drift
const y = (frame * fallSpeed + seededRandom(seed + 2) * fb.height) % (fb.height + 5);
const drift = Math.sin((frame + seed) * 0.07) * 4;
const x = Math.floor((baseX + drift + frame * 0.1 + fb.width) % fb.width);
if (y >= 0 && y < fb.height) {
const charIdx = Math.floor(seededRandom(seed + 3) * chars.length);
fb.setPixel(x, Math.floor(y), chars[charIdx], depth);
}
}
}
/**
* Rising embers/sparks
*/
function embers(fb, frame, options = {}) {
const { count = 10, chars = ['.', '·', '*'], depth = 50, speed = 1 } = options;
for (let i = 0; i < count; i++) {
const seed = i * 41;
const baseX = seededRandom(seed) * fb.width;
const riseSpeed = 0.3 + seededRandom(seed + 1) * 0.4 * speed;
// Embers rise from bottom
const y = fb.height - (frame * riseSpeed + seededRandom(seed + 2) * fb.height) % (fb.height + 5);
// Slight random drift
const drift = Math.sin((frame * 0.5 + seed) * 0.2) * 2;
const x = Math.floor((baseX + drift + fb.width) % fb.width);
// Fade as they rise
const fadeProgress = 1 - y / fb.height;
const charIdx = Math.floor(fadeProgress * (chars.length - 1));
if (y >= 0 && y < fb.height) {
fb.setPixel(x, Math.floor(y), chars[charIdx], depth);
}
}
}
/**
* Dust motes floating in light
*/
function dust(fb, frame, options = {}) {
const { density = 0.003, char = '·', depth = 100 } = options;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const seed = x * 31 + y * 17;
if (seededRandom(seed) < density) {
// Slow floating motion
const floatX = Math.sin((frame * 0.05 + seed) * 0.3) * 2;
const floatY = Math.cos((frame * 0.05 + seed * 2) * 0.2) * 1;
const drawX = Math.floor((x + floatX + fb.width) % fb.width);
const drawY = Math.floor((y + floatY + fb.height) % fb.height);
// Occasional visibility toggle
const visible = ((frame + seed) % 40) < 30;
if (visible) {
fb.setPixel(drawX, drawY, char, depth);
}
}
}
}
}
/**
* Generic floating particles (refactored from existing)
*/
function floatingParticles(fb, frame, options = {}) {
const { count = 12, char = '◇', depth = 50, speed = 1 } = options;
for (let i = 0; i < count; i++) {
const seed = i * 23;
const baseX = seededRandom(seed) * fb.width;
const baseY = seededRandom(seed + 1) * fb.height;
const floatSpeed = 0.3 + seededRandom(seed + 2) * 0.3;
// Upward float with some variation
const y = (baseY - frame * floatSpeed * speed + fb.height * 2) % fb.height;
const sway = Math.sin((frame + seed) * 0.1) * 2;
const x = Math.floor((baseX + sway + fb.width) % fb.width);
fb.setPixel(x, Math.floor(y), char, depth);
}
}
/**
* Trail effect - particles following a path
*/
function trail(fb, points, frame, options = {}) {
const { char = '·', fade = true, depth = 50 } = options;
const trailChars = fade ? ['@', '#', '*', '·', '.'] : [char];
for (let i = 0; i < points.length; i++) {
const [x, y] = points[i];
if (x >= 0 && x < fb.width && y >= 0 && y < fb.height) {
const charIdx = fade ? Math.min(i, trailChars.length - 1) : 0;
fb.setPixel(Math.floor(x), Math.floor(y), trailChars[charIdx], depth);
}
}
}
/**
* Orbiting particles around a center point
*/
function orbit(fb, frame, options = {}) {
const { cx = null, cy = null, radius = 5, count = 4, char = '*', depth = 50, speed = 1 } = options;
const centerX = cx !== null ? cx : fb.width / 2;
const centerY = cy !== null ? cy : fb.height / 2;
const aspectRatio = 2.16;
for (let i = 0; i < count; i++) {
const angle = (frame * 0.05 * speed) + (i / count) * Math.PI * 2;
const x = Math.floor(centerX + Math.cos(angle) * radius * aspectRatio);
const y = Math.floor(centerY + Math.sin(angle) * radius);
if (x >= 0 && x < fb.width && y >= 0 && y < fb.height) {
fb.setPixel(x, y, char, depth);
}
}
}
/**
* Shooting stars
*/
function shootingStars(fb, frame, options = {}) {
const { count = 2, chars = ['★', '*', '·', '.'], depth = 50 } = options;
for (let i = 0; i < count; i++) {
const seed = i * 73 + Math.floor(frame / 50) * 17;
const active = (frame % 50) < 20;
if (active) {
const progress = (frame % 50) / 20;
const startX = seededRandom(seed) * fb.width * 0.8;
const startY = seededRandom(seed + 1) * fb.height * 0.3;
// Diagonal trajectory
const x = Math.floor(startX + progress * 30);
const y = Math.floor(startY + progress * 10);
// Trail
for (let t = 0; t < chars.length; t++) {
const trailX = x - t * 2;
const trailY = y - t;
if (trailX >= 0 && trailX < fb.width && trailY >= 0 && trailY < fb.height) {
fb.setPixel(trailX, trailY, chars[t], depth);
}
}
}
}
}
/**
* Glitter effect - intense sparkle burst
*/
function glitter(fb, frame, options = {}) {
const { density = 0.02, chars = ['✦', '✧', '*', '·'], depth = 40 } = options;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const seed = x * 31 + y * 17 + frame * 3;
if (seededRandom(seed) < density) {
const charIdx = Math.floor(seededRandom(seed + 1) * chars.length);
fb.setPixel(x, y, chars[charIdx], depth);
}
}
}
}
// Make available globally for browser script tag usage
if (typeof globalThis !== 'undefined') {
Object.assign(globalThis, {
confetti, sparkles, burst, bubbles, hearts, musicNotes,
leaves, embers, dust, floatingParticles, trail, orbit,
shootingStars, glitter
});
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,415 @@
/* eslint-disable */
/**
* Deterministic Scene System with Seconds-Based Timing
*
* This system guarantees:
* 1. Scenes are defined in SECONDS, not frames - no hallucinated timing
* 2. Each scene has guaranteed phases: transition in, content reveal, hold, transition out
* 3. Content is NEVER shown during transitions
* 4. Hold time ensures viewers can absorb content before it disappears (default 2s)
* 5. Total animation duration is computed automatically from scene definitions
*
* Scene Phases:
* - TRANSITION_IN: Content hidden, transition effect plays (~0.5s)
* - CONTENT: Content animates in with normalized progress (0-1)
* - HOLD: Content fully visible, guaranteed reading time (default 2s)
* - TRANSITION_OUT: Fade/transition out begins (~0.5s)
*
* Usage:
* const SCENE_DEFINITIONS = [
* { name: 'opening', duration: 5 }, // 2s default hold
* { name: 'tarot', duration: 7.5, hold: 3 }, // 3s hold for dense content
* { name: 'closing', duration: 4, hold: 1.5 }, // 1.5s hold
* ];
*/
(function() {
const FPS = 24; // Standard frame rate
// Default timing in seconds
const DEFAULT_TRANSITION_IN_SECONDS = 0.5; // 0.5s transition in
const DEFAULT_TRANSITION_OUT_SECONDS = 0.5; // 0.5s transition out
const DEFAULT_HOLD_SECONDS = 2; // 2s hold before transition out
// Minimum content time to prevent scenes that are all transitions
const MIN_CONTENT_SECONDS = 0.5;
/**
* Calculate phase percentages from seconds-based timing
*
* @param {number} durationSeconds - Total scene duration in seconds
* @param {number} holdSeconds - Hold time in seconds
* @param {number} transitionInSeconds - Transition in time in seconds
* @param {number} transitionOutSeconds - Transition out time in seconds
* @returns {object} Phase percentages { TRANSITION_IN, CONTENT, HOLD, TRANSITION_OUT }
*/
function calculatePhases(durationSeconds, holdSeconds, transitionInSeconds, transitionOutSeconds) {
// Validate: ensure we have enough time for all phases
const fixedTime = transitionInSeconds + holdSeconds + transitionOutSeconds;
if (fixedTime >= durationSeconds) {
// Not enough time - scale down proportionally but keep minimum content time
const availableForFixed = durationSeconds - MIN_CONTENT_SECONDS;
const scale = availableForFixed / fixedTime;
transitionInSeconds *= scale;
holdSeconds *= scale;
transitionOutSeconds *= scale;
}
// Calculate percentages
const transitionIn = transitionInSeconds / durationSeconds;
const transitionOut = transitionOutSeconds / durationSeconds;
const hold = holdSeconds / durationSeconds;
const content = 1 - transitionIn - hold - transitionOut;
return {
TRANSITION_IN: transitionIn,
CONTENT: content,
HOLD: hold,
TRANSITION_OUT: transitionOut,
};
}
/**
* SceneManager - The main class for managing scene-based animations
*
* Usage:
* const manager = new SceneManager([
* { name: 'intro', duration: 5 }, // 5 seconds, 2s default hold
* { name: 'stats', duration: 8, hold: 3 }, // 8 seconds, 3s hold
* { name: 'closing', duration: 4 }, // 4 seconds, 2s default hold
* ]);
*
* // In your animation:
* const scene = manager.getSceneAt(frame);
* // scene = { name: 'intro', phase: 'CONTENT', contentProgress: 0.6, ... }
*/
class SceneManager {
constructor(sceneDefinitions, options = {}) {
this.fps = options.fps || FPS;
this.defaultHold = options.defaultHold ?? DEFAULT_HOLD_SECONDS;
this.defaultTransitionIn = options.defaultTransitionIn ?? DEFAULT_TRANSITION_IN_SECONDS;
this.defaultTransitionOut = options.defaultTransitionOut ?? DEFAULT_TRANSITION_OUT_SECONDS;
// Build scene list with computed frame ranges
this.scenes = [];
let currentFrame = 0;
for (const def of sceneDefinitions) {
const durationFrames = Math.round(def.duration * this.fps);
// Get timing values (seconds)
const holdSeconds = def.hold ?? this.defaultHold;
const transitionInSeconds = def.transitionIn ?? this.defaultTransitionIn;
const transitionOutSeconds = def.transitionOut ?? this.defaultTransitionOut;
// Calculate phase percentages from seconds
const phases = calculatePhases(
def.duration,
holdSeconds,
transitionInSeconds,
transitionOutSeconds
);
this.scenes.push({
name: def.name,
duration: def.duration,
startFrame: currentFrame,
endFrame: currentFrame + durationFrames,
durationFrames,
phases,
// Store timing info for debugging
timing: {
hold: holdSeconds,
transitionIn: transitionInSeconds,
transitionOut: transitionOutSeconds,
},
// Store any custom data
data: def.data || {},
});
currentFrame += durationFrames;
}
this.totalFrames = currentFrame;
this.totalDuration = currentFrame / this.fps;
}
/**
* Get the current scene and progress at a given frame
*
* @param {number} frame - Current frame number
* @returns {object} Scene info with phase, progress, etc.
*/
getSceneAt(frame) {
// Find which scene we're in
for (let i = 0; i < this.scenes.length; i++) {
const scene = this.scenes[i];
if (frame >= scene.startFrame && frame < scene.endFrame) {
const rawProgress = (frame - scene.startFrame) / scene.durationFrames;
const phaseInfo = getScenePhase(rawProgress, scene.phases);
return {
name: scene.name,
index: i,
frame: frame - scene.startFrame, // Frame within this scene
rawProgress,
...phaseInfo,
data: scene.data,
isFirst: i === 0,
isLast: i === this.scenes.length - 1,
};
}
}
// Past the end - return last scene at 100%
if (this.scenes.length > 0) {
const lastScene = this.scenes[this.scenes.length - 1];
return {
name: lastScene.name,
index: this.scenes.length - 1,
frame: lastScene.durationFrames,
rawProgress: 1,
phase: 'TRANSITION_OUT',
contentProgress: 1,
transitionProgress: 0,
data: lastScene.data,
isFirst: false,
isLast: true,
};
}
return null;
}
/**
* Get scene by name
*/
getSceneByName(name) {
return this.scenes.find(s => s.name === name);
}
/**
* Get total frames (useful for TOTAL_FRAMES export)
*/
getTotalFrames() {
return this.totalFrames;
}
/**
* Get total duration in seconds
*/
getTotalDuration() {
return this.totalDuration;
}
/**
* Get scene names in order
*/
getSceneNames() {
return this.scenes.map(s => s.name);
}
/**
* Simplified scene getter that returns { sceneId, progress }
* This is a convenience wrapper around getSceneAt for simpler animations
*
* @param {number} frame - Current frame number
* @returns {object} { sceneId, progress } where progress is 0-1
*/
getScene(frame) {
const info = this.getSceneAt(frame);
if (!info) {
return { sceneId: null, progress: 1 };
}
return {
sceneId: info.name,
progress: info.rawProgress,
};
}
/**
* Debug: Print scene timing info
*/
debugTiming() {
console.log('Scene Timing:');
console.log('=============');
for (const scene of this.scenes) {
const { phases, timing } = scene;
console.log(`${scene.name} (${scene.duration}s):`);
console.log(` Transition In: ${(phases.TRANSITION_IN * 100).toFixed(1)}% (${timing.transitionIn}s)`);
console.log(` Content: ${(phases.CONTENT * 100).toFixed(1)}% (${(phases.CONTENT * scene.duration).toFixed(1)}s)`);
console.log(` Hold: ${(phases.HOLD * 100).toFixed(1)}% (${timing.hold}s)`);
console.log(` Transition Out: ${(phases.TRANSITION_OUT * 100).toFixed(1)}% (${timing.transitionOut}s)`);
}
console.log(`\nTotal: ${this.totalDuration}s (${this.totalFrames} frames)`);
}
}
/**
* Calculate which phase we're in and the progress within that phase
*
* @param {number} rawProgress - Scene progress 0-1
* @param {object} phases - Phase durations as percentages
* @returns {object} { phase, contentProgress, transitionProgress }
*/
function getScenePhase(rawProgress, phases) {
const p = Math.max(0, Math.min(1, rawProgress));
// Phase 1: TRANSITION_IN
if (p < phases.TRANSITION_IN) {
return {
phase: 'TRANSITION_IN',
contentProgress: 0, // Content NOT visible yet
transitionProgress: p / phases.TRANSITION_IN,
};
}
// Phase 2: CONTENT
const contentStart = phases.TRANSITION_IN;
const contentEnd = 1 - phases.HOLD - phases.TRANSITION_OUT;
if (p < contentEnd) {
return {
phase: 'CONTENT',
contentProgress: (p - contentStart) / (contentEnd - contentStart),
transitionProgress: 1, // Transition complete
};
}
// Phase 3: HOLD
const holdEnd = 1 - phases.TRANSITION_OUT;
if (p < holdEnd) {
return {
phase: 'HOLD',
contentProgress: 1, // Content fully revealed
transitionProgress: 1,
};
}
// Phase 4: TRANSITION_OUT
return {
phase: 'TRANSITION_OUT',
contentProgress: 1, // Content stays visible during fade
transitionProgress: (1 - p) / phases.TRANSITION_OUT,
};
}
/**
* Render a scene with automatic transition handling
*
* @param {object} fb - Framebuffer
* @param {number} frame - Current frame number
* @param {number} rawProgress - Scene progress 0-1
* @param {object} config - Scene configuration
* @param {function} config.background - Background renderer (fb, frame)
* @param {function} config.content - Content renderer (fb, frame, contentProgress)
* @param {function} config.transitionIn - Transition in effect (fb, progress, frame)
* @param {function} config.transitionOut - Transition out effect (fb, progress, frame)
* @param {object} config.phases - Optional custom phase durations
*/
function renderScene(fb, frame, rawProgress, config) {
const phases = config.phases;
const { phase, contentProgress, transitionProgress } = getScenePhase(rawProgress, phases);
// Always render background first (visible through transitions)
if (config.background) {
config.background(fb, frame);
}
// Only render content after transition-in completes
if (phase !== 'TRANSITION_IN' && config.content) {
config.content(fb, frame, contentProgress);
}
// Apply transition-in effect (masks/reveals content)
if (phase === 'TRANSITION_IN' && config.transitionIn) {
config.transitionIn(fb, transitionProgress, frame);
}
// Apply transition-out effect (fades/masks content)
if (phase === 'TRANSITION_OUT' && config.transitionOut) {
// transitionProgress goes from 1 to 0 during TRANSITION_OUT
// Most transition functions expect 0-1 for "amount visible"
config.transitionOut(fb, transitionProgress, frame);
}
}
/**
* Create a scene config helper for common patterns
*/
function createScene(options) {
return {
background: options.background || null,
content: options.content || null,
transitionIn: options.transitionIn || null,
transitionOut: options.transitionOut || null,
phases: options.phases,
};
}
/**
* Helper: Create staggered reveal timing for multiple items
*
* Returns a function that takes contentProgress and item index,
* and returns the item's individual progress (0-1).
*
* @param {number} itemCount - Number of items to stagger
* @param {number} overlap - How much items overlap (0 = sequential, 1 = all at once)
* @returns {function} (contentProgress, itemIndex) => itemProgress
*/
function staggeredReveal(itemCount, overlap = 0.5) {
return (contentProgress, itemIndex) => {
if (itemCount <= 1) return contentProgress;
const itemDuration = 1 / (1 + (itemCount - 1) * (1 - overlap));
const itemStart = itemIndex * itemDuration * (1 - overlap);
const itemEnd = itemStart + itemDuration;
if (contentProgress < itemStart) return 0;
if (contentProgress >= itemEnd) return 1;
return (contentProgress - itemStart) / itemDuration;
};
}
/**
* Helper: Ease in/out function
*/
function easeInOut(t) {
return t * t * (3 - 2 * t);
}
/**
* Helper: Ease out function (fast start, slow end)
*/
function easeOut(t) {
return 1 - Math.pow(1 - t, 3);
}
/**
* Helper: Animate a counter from 0 to target
*/
function animateCounter(target, progress) {
return Math.floor(target * easeOut(progress));
}
// Export to globalThis for browser usage
if (typeof globalThis !== 'undefined') {
Object.assign(globalThis, {
SceneManager,
getScenePhase,
renderScene,
createScene,
staggeredReveal,
easeInOut,
easeOut,
animateCounter,
ANIMATION_FPS: FPS,
DEFAULT_HOLD_SECONDS,
DEFAULT_TRANSITION_IN_SECONDS,
DEFAULT_TRANSITION_OUT_SECONDS,
});
}
})();

View File

@@ -0,0 +1,503 @@
/* eslint-disable */
/**
* Text Animation Effects
* Utilities for animating text in various ways
*/
(function() {
// Seeded random for consistent effects
function seededRandom(seed) {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
// Easing function
function easeOut(t) {
return 1 - Math.pow(1 - t, 3);
}
/**
* Typewriter effect - returns substring of text
* @returns The portion of text to display
*/
function typewriter(text, progress) {
const numChars = Math.floor(progress * text.length);
return text.slice(0, numChars);
}
/**
* Fade text by letter using density characters
* @returns Array of { char, opacity } for each character
*/
function fadeByLetter(text, progress) {
const densityChars = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
return text.split('').map((char, i) => {
const charProgress = Math.max(0, Math.min(1, progress * text.length - i));
if (char === ' ') return { char: ' ', opacity: 1 };
const densityIdx = Math.floor(charProgress * (densityChars.length - 1));
return { char: densityChars[densityIdx], opacity: charProgress };
});
}
/**
* Wave position modifier - returns y offset for bouncing effect
* @param index - Character index for phase offset
*/
function wave(frame, options = {}) {
const { amplitude = 1, frequency = 0.2, index = 0, speed = 1 } = options;
return Math.round(Math.sin((frame * speed + index) * frequency) * amplitude);
}
/**
* Bounce position modifier - returns y offset for bounce effect
*/
function bounce(frame, options = {}) {
const { amplitude = 2, speed = 1 } = options;
const t = (frame * speed * 0.1) % (Math.PI * 2);
return Math.round(Math.abs(Math.sin(t)) * amplitude);
}
/**
* Shake position modifier - returns {x, y} offset for trembling
*/
function shake(frame, options = {}) {
const { intensity = 1 } = options;
return {
x: Math.round((seededRandom(frame * 7) - 0.5) * 2 * intensity),
y: Math.round((seededRandom(frame * 13) - 0.5) * 2 * intensity)
};
}
/**
* Float position modifier - gentle up/down drift
*/
function float(frame, options = {}) {
const { amplitude = 0.5, speed = 0.5 } = options;
return Math.round(Math.sin(frame * speed * 0.1) * amplitude);
}
/**
* Draw text with typewriter effect
*/
function drawTypewriter(fb, x, y, text, progress, options = {}) {
const { cursor = '▌', depth = 0 } = options;
const visibleText = typewriter(text, progress);
fb.drawText(x, y, visibleText, depth);
// Draw blinking cursor
if (progress < 1) {
const cursorX = x + visibleText.length;
fb.setPixel(cursorX, y, cursor, depth);
}
}
/**
* Draw centered text with typewriter effect
*/
function drawTypewriterCentered(fb, y, text, progress, options = {}) {
const x = Math.floor((fb.width - text.length) / 2);
drawTypewriter(fb, x, y, text, progress, options);
}
/**
* Draw text with wave effect (each character bobs up/down)
*/
function drawWaveText(fb, y, text, frame, options = {}) {
const { amplitude = 1, frequency = 0.3, speed = 1, depth = 0 } = options;
const x = Math.floor((fb.width - text.length) / 2);
for (let i = 0; i < text.length; i++) {
const offsetY = wave(frame, { amplitude, frequency, index: i, speed });
fb.setPixel(x + i, y + offsetY, text[i], depth);
}
}
/**
* Draw text with glitch effect
*/
function drawGlitchText(fb, x, y, text, frame, options = {}) {
const { intensity = 0.1, depth = 0 } = options;
const glitchChars = '#@$%&*!?░▒▓';
for (let i = 0; i < text.length; i++) {
const seed = i * 17 + frame * 7;
const shouldGlitch = seededRandom(seed) < intensity;
if (shouldGlitch) {
const glitchIdx = Math.floor(seededRandom(seed + 1) * glitchChars.length);
fb.setPixel(x + i, y, glitchChars[glitchIdx], depth);
} else {
fb.setPixel(x + i, y, text[i], depth);
}
}
}
/**
* Draw centered text with glitch effect
*/
function drawGlitchTextCentered(fb, y, text, frame, options = {}) {
const x = Math.floor((fb.width - text.length) / 2);
drawGlitchText(fb, x, y, text, frame, options);
}
/**
* Draw text that scatters then reassembles
*/
function drawScatterText(fb, text, progress, options = {}) {
const { cx = null, cy = null, depth = 0 } = options;
const centerX = cx !== null ? cx : fb.width / 2;
const centerY = cy !== null ? cy : fb.height / 2;
const finalX = Math.floor(centerX - text.length / 2);
const finalY = Math.floor(centerY);
const easedProgress = easeOut(progress);
for (let i = 0; i < text.length; i++) {
const seed = i * 31;
// Random scattered position
const scatterX = seededRandom(seed) * fb.width;
const scatterY = seededRandom(seed + 1) * fb.height;
// Interpolate between scattered and final position
const x = Math.floor(scatterX + (finalX + i - scatterX) * easedProgress);
const y = Math.floor(scatterY + (finalY - scatterY) * easedProgress);
if (x >= 0 && x < fb.width && y >= 0 && y < fb.height) {
fb.setPixel(x, y, text[i], depth);
}
}
}
/**
* Slide text in from edge
*/
function slideIn(fb, y, text, progress, options = {}) {
const { from = 'left', depth = 0 } = options;
const finalX = Math.floor((fb.width - text.length) / 2);
const easedProgress = easeOut(progress);
let startX;
switch (from) {
case 'right':
startX = fb.width;
break;
case 'left':
default:
startX = -text.length;
}
const x = Math.floor(startX + (finalX - startX) * easedProgress);
for (let i = 0; i < text.length; i++) {
const charX = x + i;
if (charX >= 0 && charX < fb.width) {
fb.setPixel(charX, y, text[i], depth);
}
}
}
/**
* Slide text out to edge
*/
function slideOut(fb, y, text, progress, options = {}) {
const { to = 'right', depth = 0 } = options;
const startX = Math.floor((fb.width - text.length) / 2);
const easedProgress = easeOut(progress);
let endX;
switch (to) {
case 'left':
endX = -text.length;
break;
case 'right':
default:
endX = fb.width;
}
const x = Math.floor(startX + (endX - startX) * easedProgress);
for (let i = 0; i < text.length; i++) {
const charX = x + i;
if (charX >= 0 && charX < fb.width) {
fb.setPixel(charX, y, text[i], depth);
}
}
}
/**
* Draw text with zoom effect (characters expand from center)
*/
function drawZoomText(fb, y, text, progress, options = {}) {
const { depth = 0 } = options;
const centerX = fb.width / 2;
const textWidth = text.length;
const easedProgress = easeOut(progress);
for (let i = 0; i < text.length; i++) {
// Start from center, expand to final position
const finalOffset = i - textWidth / 2;
const offset = finalOffset * easedProgress;
const x = Math.floor(centerX + offset);
if (x >= 0 && x < fb.width) {
fb.setPixel(x, y, text[i], depth);
}
}
}
/**
* Draw text character by character with fade
*/
function drawFadeInText(fb, y, text, progress, options = {}) {
const { depth = 0 } = options;
const x = Math.floor((fb.width - text.length) / 2);
const chars = fadeByLetter(text, progress);
for (let i = 0; i < chars.length; i++) {
if (chars[i].opacity > 0.9) {
fb.setPixel(x + i, y, text[i], depth);
} else if (chars[i].opacity > 0) {
fb.setPixel(x + i, y, chars[i].char, depth);
}
}
}
/**
* Rainbow cycling through density characters
* (Monochrome version using different characters)
*/
function drawRainbowText(fb, y, text, frame, options = {}) {
const { depth = 0, chars = ['·', '*', '◆', '●', '■'] } = options;
const x = Math.floor((fb.width - text.length) / 2);
for (let i = 0; i < text.length; i++) {
if (text[i] !== ' ') {
const cycleIdx = (i + Math.floor(frame / 4)) % chars.length;
// Keep the original character but could swap for effect chars
fb.setPixel(x + i, y, text[i], depth);
} else {
fb.setPixel(x + i, y, ' ', depth);
}
}
}
/**
* Reveal text with a wipe effect (character by character from direction)
*/
function drawWipeReveal(fb, y, text, progress, options = {}) {
const { direction = 'left', depth = 0 } = options;
const x = Math.floor((fb.width - text.length) / 2);
const numVisible = Math.floor(progress * text.length);
for (let i = 0; i < text.length; i++) {
const visibleIdx = direction === 'right' ? text.length - 1 - i : i;
if (visibleIdx < numVisible) {
fb.setPixel(x + i, y, text[i], depth);
}
}
}
// ============================================================================
// CLAUDE MASCOT & BRANDING
// ============================================================================
// Claude mascot ASCII art (Clawd - 3 lines, 10 chars wide)
const CLAUDE_MASCOT = [
' ▐▛███▜▌ ',
'▝▜█████▛▘',
' ▘▘ ▝▝ ',
];
const CLAUDE_MASCOT_WIDTH = 10;
// Claude Code logo ASCII art (large block letters, left-aligned)
const CLAUDE_CODE_LOGO = [
' ██████╗██╗ █████╗ ██╗ ██╗██████╗ ███████╗',
'██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝',
'██║ ██║ ███████║██║ ██║██║ ██║█████╗ ',
'██║ ██║ ██╔══██║██║ ██║██║ ██║██╔══╝ ',
'╚██████╗███████╗██║ ██║╚██████╔╝██████╔╝███████╗',
' ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝',
' ',
' ██████╗ ██████╗ ██████╗ ███████╗ ',
'██╔════╝██╔═══██╗██╔══██╗██╔════╝ ',
'██║ ██║ ██║██║ ██║█████╗ ',
'██║ ██║ ██║██║ ██║██╔══╝ ',
'╚██████╗╚██████╔╝██████╔╝███████╗ ',
' ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ ',
];
const CLAUDE_CODE_LOGO_WIDTH = 48;
const CLAUDE_CODE_LOGO_HEIGHT = 13;
// Claude orange color
const CLAUDE_ORANGE = '#D97757';
/**
* Draw Claude mascot (Clawd) centered at position
* @param fb - Framebuffer
* @param centerX - X center position
* @param y - Top Y position
* @param color - Color for the mascot (default: Anthropic orange)
*/
function drawClaudeMascot(fb, centerX, y, color = CLAUDE_ORANGE) {
const startX = Math.floor(centerX - CLAUDE_MASCOT_WIDTH / 2);
for (let i = 0; i < CLAUDE_MASCOT.length; i++) {
const line = CLAUDE_MASCOT[i];
for (let j = 0; j < line.length; j++) {
if (line[j] !== ' ') {
// Use setPixel with depth=0 and color for HTML, terminal ignores color
fb.setPixel(startX + j, y + i, line[j], 0, color);
}
}
}
}
/**
* Draw Claude Code logo centered at position
* @param fb - Framebuffer
* @param centerX - X center position
* @param y - Top Y position
* @param color - Color for the logo (default: Claude orange)
*/
function drawClaudeCodeLogo(fb, centerX, y, color = CLAUDE_ORANGE) {
const startX = Math.floor(centerX - CLAUDE_CODE_LOGO_WIDTH / 2);
for (let i = 0; i < CLAUDE_CODE_LOGO.length; i++) {
const line = CLAUDE_CODE_LOGO[i];
for (let j = 0; j < line.length; j++) {
if (line[j] !== ' ') {
fb.setPixel(startX + j, y + i, line[j], 0, color);
}
}
}
}
/**
* Draw the Thinkback intro scene with Clawd, Claude Code logo, and standard text
* This creates a consistent opening experience for all Thinkback animations.
*
* @param fb - Framebuffer
* @param frame - Current frame number
* @param progress - Scene progress (0-1)
* @param options - Configuration options
* @param options.year - Year to display (default: 2025)
* @param options.static - If true, render everything fully visible (for thumbnails/stills)
* @param options.staticFrame - Frame number to render as static (default: 0)
*
* @example
* drawThinkbackIntro(fb, frame, progress);
* // For a still frame:
* drawThinkbackIntro(fb, 0, 1, { static: true });
*/
function drawThinkbackIntro(fb, frame, progress, options = {}) {
const { year = 2025, static: isStatic = false, staticFrame = 0 } = options;
// Frame 0 (or specified staticFrame) is always rendered as a complete still
// This provides a good thumbnail when converting to video
const renderAsStatic = isStatic || frame === staticFrame;
const centerX = fb.width / 2;
// If static mode, skip all animations and show everything fully rendered
const logoPhase = renderAsStatic ? 1 : Math.min(1, progress * 5);
const textPhase = renderAsStatic ? 1 : Math.max(0, Math.min(1, (progress - 0.2) * 4));
const subtitlePhase = renderAsStatic ? 1 : Math.max(0, Math.min(1, (progress - 0.5) * 3));
const showYear = renderAsStatic || progress > 0.6;
// Draw Clawd mascot at top center (no pixelation - appears immediately)
const clawdY = 2;
drawClaudeMascot(fb, centerX, clawdY, CLAUDE_ORANGE);
// Draw Claude Code logo below Clawd with fast dissolve effect
const logoY = 6;
const startX = Math.floor(centerX - CLAUDE_CODE_LOGO_WIDTH / 2);
for (let i = 0; i < CLAUDE_CODE_LOGO.length; i++) {
const line = CLAUDE_CODE_LOGO[i];
for (let j = 0; j < line.length; j++) {
if (line[j] !== ' ') {
// Dissolve effect - random pixels appear based on progress
// In static mode (including frame 0), always show all pixels
const seed = i * 100 + j;
const threshold = seededRandom(seed);
if (renderAsStatic || logoPhase > threshold) {
const x = startX + j;
if (x >= 0 && x < fb.width) {
fb.setPixel(x, logoY + i, line[j], 0, CLAUDE_ORANGE);
}
}
}
}
}
// Draw "Think Back on..." text
if (textPhase > 0 || renderAsStatic) {
const thinkBackY = 21;
const thinkBackText = 'Think Back on...';
// Typewriter effect for "Think Back on..." (or full text in static mode)
const visibleChars = renderAsStatic ? thinkBackText.length : Math.floor(textPhase * thinkBackText.length * 1.2);
const displayText = thinkBackText.slice(0, Math.min(visibleChars, thinkBackText.length));
const textX = Math.floor(centerX - thinkBackText.length / 2);
for (let i = 0; i < displayText.length; i++) {
fb.setPixel(textX + i, thinkBackY, displayText[i], 0, '#FFFFFF');
}
// Draw cursor while typing (not in static mode)
if (!renderAsStatic && visibleChars < thinkBackText.length) {
fb.setPixel(textX + displayText.length, thinkBackY, '▌', 0, '#FFFFFF');
}
}
// Draw "your year with Claude Code" only after "Think Back on..." is fully typed
if (subtitlePhase > 0 || renderAsStatic) {
const subtitleY = 23;
const subtitleText = 'your year with Claude Code';
const easedSubtitle = easeOut(subtitlePhase);
const visibleChars = renderAsStatic ? subtitleText.length : Math.floor(easedSubtitle * subtitleText.length);
const displaySubtitle = subtitleText.slice(0, visibleChars);
const subtitleX = Math.floor(centerX - subtitleText.length / 2);
for (let i = 0; i < displaySubtitle.length; i++) {
fb.setPixel(subtitleX + i, subtitleY, displaySubtitle[i], 0, CLAUDE_ORANGE);
}
// Draw cursor while typing (not in static mode)
if (!renderAsStatic && visibleChars < subtitleText.length) {
fb.setPixel(subtitleX + displaySubtitle.length, subtitleY, '▌', 0, CLAUDE_ORANGE);
}
}
// Draw year at bottom
if (showYear) {
const yearPhase = renderAsStatic ? 1 : (progress - 0.6) / 0.4;
const yearText = String(year);
const yearY = fb.height - 3;
const yearX = Math.floor(centerX - yearText.length / 2);
// Fade in year (or show immediately in static mode)
if (renderAsStatic || yearPhase > 0.3) {
for (let i = 0; i < yearText.length; i++) {
fb.setPixel(yearX + i, yearY, yearText[i], 0, '#FFD700');
}
}
}
}
// Make available globally for browser script tag usage
if (typeof globalThis !== 'undefined') {
Object.assign(globalThis, {
typewriter, fadeByLetter, wave, bounce, shake, float,
drawTypewriter, drawTypewriterCentered, drawWaveText,
drawGlitchText, drawGlitchTextCentered, drawScatterText,
slideIn, slideOut, drawZoomText, drawFadeInText,
drawRainbowText, drawWipeReveal,
// Claude branding
CLAUDE_MASCOT, CLAUDE_MASCOT_WIDTH, drawClaudeMascot,
CLAUDE_CODE_LOGO, CLAUDE_CODE_LOGO_WIDTH, CLAUDE_CODE_LOGO_HEIGHT,
CLAUDE_ORANGE, drawClaudeCodeLogo, drawThinkbackIntro,
});
}
})();

View File

@@ -0,0 +1,449 @@
/* eslint-disable */
/**
* Scene Transition Effects
* All functions take (fb, progress, options) where progress is 0-1
*/
(function() {
// Seeded random for consistent dissolve patterns
function seededRandom(seed) {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
/**
* Wipe from right to left (reveals content as wipe passes)
*/
function wipeLeft(fb, progress, char = '█') {
const edge = Math.floor((1 - progress) * fb.width);
for (let y = 0; y < fb.height; y++) {
for (let x = edge; x < fb.width; x++) {
fb.setPixel(x, y, char, -100);
}
}
}
/**
* Wipe from left to right
*/
function wipeRight(fb, progress, char = '█') {
const edge = Math.floor(progress * fb.width);
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < edge; x++) {
fb.setPixel(x, y, char, -100);
}
}
}
/**
* Wipe from top to bottom
*/
function wipeDown(fb, progress, char = '█') {
const edge = Math.floor(progress * fb.height);
for (let y = 0; y < edge; y++) {
for (let x = 0; x < fb.width; x++) {
fb.setPixel(x, y, char, -100);
}
}
}
/**
* Wipe from bottom to top
*/
function wipeUp(fb, progress, char = '█') {
const edge = Math.floor((1 - progress) * fb.height);
for (let y = edge; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
fb.setPixel(x, y, char, -100);
}
}
}
/**
* Circular reveal expanding from center
* Uses aspect ratio correction for circular appearance
*/
function circleReveal(fb, progress, cx = null, cy = null) {
const centerX = cx !== null ? cx : fb.width / 2;
const centerY = cy !== null ? cy : fb.height / 2;
const maxRadius = Math.sqrt(fb.width * fb.width + fb.height * fb.height);
const radius = progress * maxRadius;
const aspectRatio = 2.16; // Terminal character aspect ratio
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const dx = (x - centerX) / aspectRatio;
const dy = y - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > radius) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
/**
* Circular close - shrinks to center
*/
function circleClose(fb, progress, cx = null, cy = null) {
circleReveal(fb, 1 - progress, cx, cy);
}
/**
* Classic iris-in effect (circle reveals from center)
*/
function irisIn(fb, progress) {
circleReveal(fb, progress);
}
/**
* Classic iris-out effect (circle closes to center)
*/
function irisOut(fb, progress) {
circleClose(fb, progress);
}
/**
* Horizontal venetian blinds effect
*/
function blindsH(fb, progress, numBlinds = 8) {
const blindHeight = Math.ceil(fb.height / numBlinds);
const revealHeight = Math.floor(progress * blindHeight);
for (let blind = 0; blind < numBlinds; blind++) {
const blindStart = blind * blindHeight;
for (let dy = revealHeight; dy < blindHeight; dy++) {
const y = blindStart + dy;
if (y < fb.height) {
for (let x = 0; x < fb.width; x++) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
}
/**
* Vertical blinds effect
*/
function blindsV(fb, progress, numBlinds = 12) {
const blindWidth = Math.ceil(fb.width / numBlinds);
const revealWidth = Math.floor(progress * blindWidth);
for (let blind = 0; blind < numBlinds; blind++) {
const blindStart = blind * blindWidth;
for (let dx = revealWidth; dx < blindWidth; dx++) {
const x = blindStart + dx;
if (x < fb.width) {
for (let y = 0; y < fb.height; y++) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
}
/**
* Checkerboard dissolve pattern
*/
function checkerboard(fb, progress, size = 4) {
const numCellsX = Math.ceil(fb.width / size);
const numCellsY = Math.ceil(fb.height / size);
const totalCells = numCellsX * numCellsY;
const revealedCells = Math.floor(progress * totalCells);
// Create ordered reveal pattern (checkerboard pattern)
const cells = [];
for (let cy = 0; cy < numCellsY; cy++) {
for (let cx = 0; cx < numCellsX; cx++) {
const phase = (cx + cy) % 2;
cells.push({ cx, cy, order: phase * totalCells / 2 + cy * numCellsX + cx });
}
}
cells.sort((a, b) => a.order - b.order);
// Cover unrevealed cells
for (let i = revealedCells; i < cells.length; i++) {
const { cx, cy } = cells[i];
for (let dy = 0; dy < size; dy++) {
for (let dx = 0; dx < size; dx++) {
const x = cx * size + dx;
const y = cy * size + dy;
if (x < fb.width && y < fb.height) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
}
/**
* Diagonal wipe from corner to corner
* @param dir - 'tl' (top-left), 'tr', 'bl', 'br'
*/
function diagonalWipe(fb, progress, dir = 'tl') {
const maxDist = fb.width + fb.height;
const threshold = progress * maxDist;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
let dist;
switch (dir) {
case 'tl': dist = x + y; break;
case 'tr': dist = (fb.width - x) + y; break;
case 'bl': dist = x + (fb.height - y); break;
case 'br': dist = (fb.width - x) + (fb.height - y); break;
default: dist = x + y;
}
if (dist > threshold) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
/**
* Random pixel dissolve effect
*/
function dissolve(fb, progress, seed = 0) {
const totalPixels = fb.width * fb.height;
const visiblePixels = Math.floor(progress * totalPixels);
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const pixelSeed = seed + x * 31 + y * 17;
const rand = seededRandom(pixelSeed);
if (rand > progress) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
/**
* Pixelation effect - content becomes less pixelated over time
*/
function pixelate(fb, progress, maxSize = 8) {
if (progress >= 1) return;
const blockSize = Math.max(1, Math.floor((1 - progress) * maxSize));
if (blockSize <= 1) return;
// Sample and replicate blocks
for (let by = 0; by < fb.height; by += blockSize) {
for (let bx = 0; bx < fb.width; bx += blockSize) {
// Get center pixel of block
const sampleX = Math.min(bx + Math.floor(blockSize / 2), fb.width - 1);
const sampleY = Math.min(by + Math.floor(blockSize / 2), fb.height - 1);
const char = fb.getPixel(sampleX, sampleY);
// Fill block with sampled character
for (let dy = 0; dy < blockSize; dy++) {
for (let dx = 0; dx < blockSize; dx++) {
const x = bx + dx;
const y = by + dy;
if (x < fb.width && y < fb.height) {
fb.setPixel(x, y, char, -50);
}
}
}
}
}
}
/**
* Matrix rain transition effect
*/
function matrixRain(fb, frame, progress, density = 0.1) {
const chars = '01';
const visibleColumns = Math.floor(progress * fb.width);
for (let x = 0; x < visibleColumns; x++) {
const columnSeed = x * 17;
const speed = 0.5 + seededRandom(columnSeed) * 0.5;
const offset = Math.floor(frame * speed + seededRandom(columnSeed + 1) * fb.height);
for (let y = 0; y < fb.height; y++) {
const charSeed = columnSeed + y * 31 + frame;
if (seededRandom(charSeed) < density) {
const trailPos = (y + offset) % fb.height;
const charIdx = Math.floor(seededRandom(charSeed + 7) * chars.length);
fb.setPixel(x, trailPos, chars[charIdx], -80);
}
}
}
}
/**
* Slide offset calculator - returns offset for sliding content
* Use text_effects.js slideIn/slideOut for drawing text
*/
function getSlideOffset(progress, from = 'left', width, height) {
const offset = Math.floor((1 - progress) * (from === 'left' || from === 'right' ? width : height));
return {
x: from === 'left' ? -offset : from === 'right' ? offset : 0,
y: from === 'top' ? -offset : from === 'bottom' ? offset : 0
};
}
/**
* Fade using density characters
*/
function fade(fb, progress, invert = false) {
const densityChars = [' ', '.', ':', '-', '=', '+', '*', '#', '%', '@'];
const p = invert ? 1 - progress : progress;
const charIdx = Math.floor(p * (densityChars.length - 1));
const fadeChar = densityChars[charIdx];
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const current = fb.getPixel(x, y);
if (current !== ' ') {
fb.setPixel(x, y, fadeChar, -90);
}
}
}
}
// Rainbow color helper for effects
function rainbowColor(index, offset = 0) {
const colors = ['#9C5C5C', '#CC785C', '#B8A85C', '#5C9A5C', '#5C9A9A', '#5C7A9C', '#8C6C9C'];
return colors[Math.abs(Math.floor(index + offset)) % colors.length];
}
// ============================================================================
// SUPERNOVA - Explosive burst from center
// ============================================================================
function supernova(fb, progress) {
const centerX = fb.width / 2;
const centerY = fb.height / 2;
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY) * 1.2;
const currentRadius = progress * maxRadius;
const chars = '✦✧*·';
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const dx = (x - centerX) / 2; // Compensate for character aspect ratio
const dy = y - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < currentRadius) {
// Inside the explosion - clear
fb.setPixel(x, y, ' ', -100);
} else if (dist < currentRadius + 3) {
// Edge of explosion - draw particles
const charIdx = Math.floor(seededRandom(x * 31 + y * 17) * chars.length);
fb.setPixel(x, y, chars[charIdx], -100);
}
}
}
}
// ============================================================================
// SPIRAL - Spinning vortex clear
// ============================================================================
function spiral(fb, progress) {
const centerX = fb.width / 2;
const centerY = fb.height / 2;
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
const dx = (x - centerX) / 2;
const dy = y - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx) + Math.PI; // 0 to 2π
// Spiral threshold: combines angle and distance
const threshold = (angle / (Math.PI * 2) + dist / maxRadius) / 2;
if (threshold < progress) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
// ============================================================================
// SHATTER - Breaking glass effect
// ============================================================================
function shatter(fb, progress) {
const numShards = 20;
for (let i = 0; i < numShards; i++) {
// Each shard has a random position and falls at different speeds
const shardX = seededRandom(i * 17) * fb.width;
const shardY = seededRandom(i * 31) * fb.height;
const shardSize = 3 + Math.floor(seededRandom(i * 47) * 5);
const fallSpeed = 0.5 + seededRandom(i * 61);
const shardProgress = Math.min(1, progress * (1 + fallSpeed));
if (shardProgress > seededRandom(i * 73) * 0.5) {
// Clear this shard area
for (let dy = 0; dy < shardSize; dy++) {
for (let dx = 0; dx < shardSize * 2; dx++) {
const px = Math.floor(shardX + dx);
const py = Math.floor(shardY + dy + shardProgress * 10);
if (px >= 0 && px < fb.width && py >= 0 && py < fb.height) {
fb.setPixel(px, py, ' ', -100);
}
}
}
}
}
// Ensure full clear at end
if (progress >= 0.95) {
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
// ============================================================================
// TORNADO - Swirling funnel clear
// ============================================================================
function tornado(fb, progress) {
const centerX = fb.width / 2;
const rotation = progress * Math.PI * 6; // 3 full rotations
for (let y = 0; y < fb.height; y++) {
// Funnel width varies with height (wider at top)
const funnelWidth = (fb.height - y) / fb.height * fb.width * 0.4;
const offset = Math.sin(rotation + y * 0.3) * funnelWidth * progress;
const clearStart = Math.floor(centerX + offset - funnelWidth * progress);
const clearEnd = Math.floor(centerX + offset + funnelWidth * progress);
for (let x = clearStart; x < clearEnd; x++) {
if (x >= 0 && x < fb.width) {
fb.setPixel(x, y, ' ', -100);
}
}
}
// Ensure full clear at end
if (progress >= 0.95) {
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
// Make available globally for browser script tag usage
if (typeof globalThis !== 'undefined') {
Object.assign(globalThis, {
wipeLeft, wipeRight, wipeDown, wipeUp,
circleReveal, circleClose, irisIn, irisOut,
blindsH, blindsV, checkerboard, diagonalWipe,
dissolve, pixelate, matrixRain, getSlideOffset, fade,
supernova, spiral, shatter, tornado
});
}
})();

View File

@@ -0,0 +1,437 @@
# High Token Version - Deep Dive Generation
This mode creates a deeply personalized Thinkback by analyzing the user's projects, commits, and conversations.
## Narrative Philosophy
Each animation is deeply personalized to the user. While it is informed by stats it is not ABOUT stats.
It is instead about telling a story about the user.
It is structured in scenes with an opening that draws the user and a closing that wraps it up nicely.
### Narrative Principles
1. **Stats are evidence, not the story** - Don't just show "106 commits in October." Ask: *What was happening in October? What were they building? Why did it matter?*
2. **Find the defining moments & accomplishments** - Look for:
- Their first moment with Claude code
- A major feature or PR that shipped
- A problem they solved repeatedly
- A shift in what they worked on (new repo, new area)
- Patterns that reveal personality (night owl, weekend warrior, refactor-then-ship)
3. **Create emotional beats** - Each scene should make the user feel something:
- Opening: Anticipation, curiosity
- Middle: Recognition ("that's so me"), pride, humor
- Closing: Gratitude, momentum for next year
4. **Connect the dots** - The best narratives link scenes:
- "You started the year exploring... but by October, you were building"
- "42 commits at midnight - burning the midnight oil"
### Scene Types to Include
- **Origin moment**: How/when they started with Claude Code
- **Signature move**: What they do most (debug? refactor? prototype?)
- **Growth arc**: How their usage evolved
- **Defining projects**: The projects that mattered most or unique accomplishments
---
## Step 1: Extract Statistics
Run the stats script from the skill folder root:
```bash
cd ${CLAUDE_PLUGIN_ROOT}/skills/thinkback && node scripts/get_all_stats.js --markdown
```
The `--markdown` flag also generates `activity-report.md` with:
- Every repo with Claude co-authored commits
- Recent commits per repo (up to 10)
- Recent user messages per project (up to 5)
## Step 2: Read the Activity Report
Read `activity-report.md` for narrative inspiration - specific projects, commit messages, and conversation snippets that make the thinkback personal.
## Step 3: Spin off Subagents
Spin off subagents to read individual repos with instructions to: understand what the repo is, and then read the commits by the user to understand major accomplishments and projects that the user did.
Each subagent should return this information with the projects, what they are about and the user's accomplishments.
Also spin off an explore subagent to read the users transcripts at `~/.claude/projects` and look for specific keywords to indicate a big accomplishment or something the user was very happy about.
## Step 4: Generate year_in_review.js
Write the customized file to this skill folder as `year_in_review.js`.
**CRITICAL: SCENE_DEFINITIONS use SECONDS, not frames:**
```javascript
// CORRECT - durations in seconds:
const SCENE_DEFINITIONS = [
{ name: 'INTRO', duration: 7.5 },
{ name: 'FIRST_SPARK', duration: 7.5 },
{ name: 'THE_JOURNEY', duration: 8.5 },
// ... etc
];
// WRONG - DO NOT use frames:
const SCENE_DEFINITIONS = [
{ name: 'INTRO', frames: 180 }, // WRONG
];
```
## Step 5: Validate the Animation
**IMPORTANT: Always run validation after generating year_in_review.js to catch common errors.**
```bash
cd ${CLAUDE_PLUGIN_ROOT}/skills/thinkback && node scripts/validate.js
```
The validator checks for:
- Missing exports (YearInReviewScenes, TOTAL_FRAMES, mainAnimation)
- **Scene names being undefined** (using `id` instead of `name` in SCENE_DEFINITIONS)
- Render function errors at various frames
- Frame coverage gaps
**If validation fails, fix the errors and re-run validation until it passes.**
Common errors and fixes:
| Error | Fix |
|-------|-----|
| "scenes have undefined names" | Change `{ id: 'SCENE_NAME' }` to `{ name: 'SCENE_NAME' }` in SCENE_DEFINITIONS |
| "Render error at frame X" | Check the switch statement cases match the scene names exactly |
| "TOTAL_FRAMES is invalid" or "TOTAL_FRAMES = NaN" | Use `getScene()` correctly - it returns `{ sceneId, progress }`, NOT `{ sceneId, sceneFrame, sceneLength }` |
| Progress `p` is NaN | Wrong API: use `const { sceneId, progress } = sceneManager.getScene(frame); const p = progress;` |
| animateCounter not working | Only 2 args: `animateCounter(target, progress)` NOT `animateCounter(0, target, progress)` |
| Scenes using `frames` instead of `duration` | SCENE_DEFINITIONS use **seconds**, not frames. Use `{ name: 'SCENE', duration: 7.5 }` NOT `{ name: 'SCENE', frames: 180 }` |
## Step 6: Signal Completion
After validation passes, tell the user their animation is ready and ask them to run `/thinkback` again to play it.
---
## Required Intro Scene
**IMPORTANT: Every Thinkback animation MUST begin with a consistent intro scene.**
The intro scene uses `drawThinkbackIntro()` to display:
1. **Clawd** (the Claude mascot) at the top
2. **Claude Code** logo in large ASCII art
3. **"Think Back on..."** text with typewriter effect
4. **"your year with Claude Code"** subtitle
5. **Year** at the bottom
---
## Animation Helpers Reference
**IMPORTANT: DO NOT use ES module `import` statements in `year_in_review.js`.**
The file is loaded as a regular `<script>` tag in the browser, not as a module. All helpers are set on `globalThis` by the helper scripts that load before `year_in_review.js`.
**CRITICAL: Destructure helpers BEFORE using SceneManager.**
The `SceneManager` class must be destructured from `globalThis` before you can use `new SceneManager(...)`. Place the destructuring block at the very top of your file, before scene definitions:
```javascript
// =============================================================================
// HELPER DESTRUCTURING (must come FIRST, before SceneManager usage)
// =============================================================================
const {
// Scene system (REQUIRED - must be destructured before use)
SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
// ... other helpers
} = globalThis;
// NOW you can use SceneManager
const SCENE_DEFINITIONS = [...];
const sceneManager = new SceneManager(SCENE_DEFINITIONS); // This works!
```
Access helpers by destructuring from `globalThis` at the top of your file:
```javascript
const {
// Transitions
wipeLeft, wipeRight, wipeDown, wipeUp,
circleReveal, circleClose, irisIn, irisOut,
blindsH, blindsV, checkerboard, diagonalWipe,
dissolve, pixelate, fade,
supernova, spiral, shatter, tornado, // Creative transitions
// Backgrounds
stars, starfield, rain, snow, fog, aurora,
waves, gradient, ripples, fireflies, clouds,
// Particles
confetti, sparkles, burst, bubbles, hearts,
musicNotes, leaves, embers, dust, floatingParticles,
orbit, shootingStars, glitter,
// Text effects
typewriter, drawTypewriter, drawTypewriterCentered,
drawWaveText, drawGlitchText, slideIn, slideOut,
drawZoomText, drawFadeInText, drawScatterText,
// Claude branding & intro
CLAUDE_MASCOT, CLAUDE_MASCOT_WIDTH, drawClaudeMascot,
CLAUDE_CODE_LOGO, CLAUDE_CODE_LOGO_WIDTH, CLAUDE_CODE_LOGO_HEIGHT,
CLAUDE_ORANGE, drawClaudeCodeLogo, drawThinkbackIntro,
// News broadcast effects (for morning news vibe)
lowerThird, tickerTape, breakingBanner, liveIndicator,
segmentTitle, statCounter, forecastBar, splitWipe,
pushTransition, headlineCrawl, countdownReveal,
// Awards show effects (for awards show vibe)
trophyDisplay, awardBadge, envelopeReveal, categoryTitle,
acceptanceSpeech, nomineeCard, winnerAnnouncement, applauseMeter,
standingOvation, redCarpetBorder, spotlightText, spotlightReveal,
curtainReveal, awardsStatue,
// RPG quest effects (for rpg quest vibe)
characterSprite, titleScreen, textBox, classSelect,
questCard, questBanner, xpBar, levelUp,
statsPanel, bossHealth, victoryFanfare, creditsRoll, inventorySlot,
} = globalThis;
```
### Transitions
Transitions mask/reveal content. Apply after drawing your scene content.
```javascript
// Circular reveal from center (0-1 progress)
circleReveal(fb, progress);
// Wipe from left to right
wipeRight(fb, progress, '█');
// Diagonal wipe from top-left corner
diagonalWipe(fb, progress, 'tl');
// Venetian blinds effect (horizontal)
blindsH(fb, progress, 8);
// Random dissolve
dissolve(fb, progress, seed);
// Iris in/out (classic film transition)
irisIn(fb, progress);
irisOut(fb, progress);
// Creative transitions
supernova(fb, progress); // Explosive burst from center
spiral(fb, progress); // Spinning vortex clear
shatter(fb, progress); // Breaking glass effect
tornado(fb, progress); // Swirling funnel clear
```
### Backgrounds
Draw backgrounds first, before scene content.
```javascript
// Twinkling stars (cozy, slow twinkle)
stars(fb, frame, { density: 0.006, twinkle: true });
// 3D starfield zoom effect
starfield(fb, frame, { speed: 1, numStars: 50 });
// Fireflies (warm, cozy)
fireflies(fb, frame, { count: 8 });
// Snow falling
snow(fb, frame, { density: 0.01 });
// Aurora / northern lights
aurora(fb, frame, { intensity: 0.5 });
// Gradient background
gradient(fb, { direction: 'vertical', invert: false });
// Concentric ripples from center
ripples(fb, frame, { speed: 1, char: '·' });
```
### Particles
Particles add atmosphere and celebration.
```javascript
// Gentle floating particles
floatingParticles(fb, frame, { count: 12, char: '◇' });
// Confetti celebration
confetti(fb, frame, { count: 20 });
// Sparkles twinkling
sparkles(fb, frame, { density: 0.005 });
// Rising embers
embers(fb, frame, { count: 10 });
// Floating hearts
hearts(fb, frame, { count: 6 });
// Dust motes in light
dust(fb, frame, { density: 0.003 });
// Orbiting particles around center
orbit(fb, frame, { cx: 40, cy: 12, radius: 5, count: 4 });
```
### Text Effects
Text animations for revealing and emphasizing text.
```javascript
// Typewriter effect (returns partial text)
const visibleText = typewriter('Hello World', progress);
// Draw with typewriter + cursor
drawTypewriterCentered(fb, y, 'Hello World', progress);
// Slide text in from edge
slideIn(fb, y, 'Welcome', progress, { from: 'left' });
// Wave effect (each character bobs)
drawWaveText(fb, y, 'Wavy Text', frame, { amplitude: 1 });
// Zoom text from center
drawZoomText(fb, y, 'Zoom!', progress);
// Fade in character by character
drawFadeInText(fb, y, 'Fading in', progress);
// Scatter then reassemble
drawScatterText(fb, 'Scatter', progress);
```
### Claude Mascot (Clawd)
The Claude mascot "Clawd" is a small ASCII art logo that can be used in opener/closer scenes.
```javascript
// Draw Claude mascot centered at position
// CLAUDE_MASCOT is an array of 3 lines
// CLAUDE_MASCOT_WIDTH is 10 characters
drawClaudeMascot(fb, fb.width / 2, 5, CLAUDE_ORANGE);
// The mascot looks like:
// ▐▛███▜▌
// ▝▜█████▛▘
// ▘▘ ▝▝
```
### Claude Code Logo
Large ASCII art logo for the Claude Code branding.
```javascript
// Draw the full Claude Code logo (12 lines tall, 50 chars wide)
drawClaudeCodeLogo(fb, fb.width / 2, 5, CLAUDE_ORANGE);
// The logo displays:
// ██████╗██╗ █████╗ ██╗ ██╗██████╗ ███████╗
// ██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝
// ██║ ██║ ███████║██║ ██║██║ ██║█████╗
// ██║ ██║ ██╔══██║██║ ██║██║ ██║██╔══╝
// ╚██████╗███████╗██║ ██║╚██████╔╝██████╔╝███████╗
// ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
// ██████╗ ██████╗ ██████╗ ███████╗
// ██╔════╝██╔═══██╗██╔══██╗██╔════╝
// ██║ ██║ ██║██║ ██║█████╗
// ██║ ██║ ██║██║ ██║██╔══╝
// ╚██████╗╚██████╔╝██████╔╝███████╗
// ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
```
### Thinkback Intro Scene (REQUIRED)
**Every animation MUST use this as the first scene.** It provides a consistent, branded intro experience.
```javascript
// In your intro scene renderer:
case 'INTRO': {
// Add a starfield background
stars(fb, frame, { density: 0.008, twinkle: true });
// Draw the complete intro scene with Clawd, logo, and standard text
drawThinkbackIntro(fb, frame, p);
// Handle transition out
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
break;
}
```
The intro displays:
- Clawd mascot fading in
- Claude Code logo with dissolve effect
- "Think Back on..." with typewriter effect
- "your year with Claude Code" subtitle
- Year at the bottom
### Example Scene with Helpers
```javascript
case 'OPENING': {
// Background: twinkling stars
stars(fb, frame, { density: 0.008, twinkle: true });
// Particles: gentle floating diamonds
floatingParticles(fb, frame, { count: 15, char: '◇' });
// Text: typewriter reveal
const title = 'Your Year in Review';
if (p < 0.6) {
drawTypewriterCentered(fb, 10, title, p / 0.6);
} else {
fb.drawCenteredText(10, title);
}
// Transition: iris out at end of scene
if (p > 0.85) {
irisOut(fb, (p - 0.85) / 0.15);
}
break;
}
```
## Critical Export Format
**IMPORTANT: The generated `year_in_review.js` MUST export in this exact format for the HTML to work:**
```javascript
// =============================================================================
// EXPORTS - MUST match what year_in_review.html expects
// =============================================================================
globalThis.YearInReviewScenes = {
TOTAL_FRAMES: sceneManager.getTotalFrames(),
mainAnimation: render, // Your main render function
getSceneName: (frame) => {
const { sceneId } = sceneManager.getScene(frame);
return sceneId || 'Complete';
},
sceneManager,
};
```
**DO NOT use these incorrect patterns:**
```javascript
// WRONG - will cause "YearInReviewScenes not loaded" error
globalThis.render = render;
globalThis.TOTAL_FRAMES = sceneManager.getTotalFrames();
// WRONG - using ES module exports (not supported in browser script tag)
export { render, TOTAL_FRAMES };
```
The `year_in_review.html` file specifically checks for `globalThis.YearInReviewScenes` and calls `mainAnimation` from it.

View File

@@ -0,0 +1,129 @@
# Low Token Version - Template-Based Generation
This mode uses pre-built templates for fast, token-efficient generation. Templates handle all scene structure, timing, and visual effects - you just inject the user's stats.
## Step 1: Extract Statistics
Run the stats script:
```bash
cd ${CLAUDE_PLUGIN_ROOT}/skills/thinkback && node scripts/get_all_stats.js
```
Save the output - you'll need these values for the template.
## Step 2: Choose Template
Based on the user's vibe selection, use:
| Vibe | Template File |
|------|---------------|
| Cozy | `templates/cozy-template.js` |
| Awards show | `templates/awards-show-template.js` |
| Morning news | `templates/morning-news-template.js` |
| RPG Quest | `templates/rpg-quest-template.js` |
## Step 3: Read the Template
Read the chosen template file. Each template has injection points marked with `// INJECT:` comments.
## Step 4: Fill Injection Points
Search for `// INJECT:` comments and fill in values from the stats output.
### Common STATS Object
All templates have a STATS object to fill:
```javascript
const STATS = {
userName: '', // User's name from stats
year: 2025,
totalCommits: 0, // Total commits
totalSessions: 0, // Total sessions
totalMessages: 0, // Total messages
repoCount: 0, // Number of repos
peakHour: '', // e.g., '12am', '3pm'
peakDay: '', // e.g., 'Wed', 'Mon'
nightOwlPercent: 0, // Percentage (0-100)
earlyBirdPercent: 0, // Percentage (0-100)
weekendPercent: 0, // Percentage (0-100)
longestStreak: 0, // Days
currentStreak: 0, // Days
totalActiveDays: 0, // Days
marathonDays: 0, // Days with 100+ messages
longestSessionMessages: 0, // Messages in longest session
firstSessionDate: '', // 'YYYY-MM-DD'
busiestWeek: '', // e.g., 'Nov 24-30, 2025'
};
```
### Template-Specific Injection Points
**RPG Quest template** also needs:
```javascript
const CHARACTER_CLASS = ''; // INJECT: Based on work patterns
// Options: 'Code Knight', 'Debug Wizard', 'Refactor Monk', 'Feature Bard'
```
**Awards Show template** also needs:
```javascript
const TOP_REPOS = [
{ name: '', commits: 0 }, // INJECT: Top repo
{ name: '', commits: 0 }, // INJECT: 2nd repo
{ name: '', commits: 0 }, // INJECT: 3rd repo
];
```
## Step 5: Write to year_in_review.js
Write the filled template to `year_in_review.js` in this skill folder.
## Step 6: Validate
Run validation to ensure no errors:
```bash
cd ${CLAUDE_PLUGIN_ROOT}/skills/thinkback && node scripts/validate.js
```
If validation fails, check:
- All STATS values are filled (no empty strings for required fields)
- Numbers are actual numbers, not strings
- Arrays have the expected structure
## Step 7: Signal Completion
Tell the user their animation is ready and ask them to run `/thinkback` again to play it.
---
## Quick Reference: Stats Output to STATS Mapping
The `get_all_stats.js` output maps to STATS like this:
| Stats Output | STATS Field |
|--------------|-------------|
| `commits.total` | `totalCommits` |
| `sessions.total` | `totalSessions` |
| `messages.total` | `totalMessages` |
| `repos` array length | `repoCount` |
| `timing.peakHour` | `peakHour` |
| `timing.peakDay` | `peakDay` |
| `timing.nightOwlPercent` | `nightOwlPercent` |
| `timing.earlyBirdPercent` | `earlyBirdPercent` |
| `timing.weekendPercent` | `weekendPercent` |
| `streaks.longest` | `longestStreak` |
| `streaks.current` | `currentStreak` |
| `activity.activeDays` | `totalActiveDays` |
| `activity.marathonDays` | `marathonDays` |
| `sessions.longestMessages` | `longestSessionMessages` |
| `firstSession.date` | `firstSessionDate` |
| `activity.busiestWeek` | `busiestWeek` |
---
## Tips for Fast Generation
1. **Don't modify scene structure** - Templates have pre-tuned timing and transitions
2. **Keep STATS simple** - Just copy numbers from the stats output
3. **Skip narrative analysis** - Templates have generic but polished narratives built-in
4. **Validate immediately** - Catch typos before signaling completion

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env node
/* eslint-disable */
/**
* 2025 Year in Review - ASCII Animation (Terminal Version)
* A celebration of collaboration between Thariq and Claude Code
*/
import { AnimationEngine } from './ascii_anim.js';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { access } from 'fs/promises';
import { constants } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Helper to safely import optional modules
async function safeImport(modulePath, description) {
const fullPath = join(__dirname, modulePath);
try {
await access(fullPath, constants.F_OK);
await import(modulePath);
} catch (err) {
if (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'ENOENT') {
// Module doesn't exist, skip it silently (it's optional)
return;
}
console.error(`Error loading ${description} (${modulePath}):`, err.message);
}
}
// Helper to require import modules
async function requireImport(modulePath, description) {
try {
await import(modulePath);
} catch (err) {
console.error(`\nFailed to load ${description}:`);
console.error(` File: ${modulePath}`);
console.error(` Error: ${err.message}`);
if (err.stack) {
// Show a few lines of stack trace for context
const stackLines = err.stack.split('\n').slice(1, 4);
console.error(' Stack:', stackLines.join('\n '));
}
throw err;
}
}
// Load dependencies first (they set globalThis)
try {
await requireImport('./helpers/index.js', 'animation helpers');
} catch (err) {
console.error('\nAnimation helpers failed to load. Cannot continue.');
process.exit(1);
}
// Optional vibe-specific helpers (may not exist depending on vibe choice)
await safeImport('./rpg_class.js', 'RPG class helpers');
await safeImport('./tarot.js', 'tarot helpers');
// Check if year_in_review.js exists
const yearInReviewPath = join(__dirname, 'year_in_review.js');
try {
await access(yearInReviewPath, constants.F_OK);
} catch (err) {
console.error('\nError: year_in_review.js not found!');
console.error('Please run the /thinkback command first to generate your thinkback animation.');
process.exit(1);
}
// Load generated animation data (reads from globalThis, sets globalThis.YearInReviewScenes)
try {
await import('./year_in_review.js');
} catch (err) {
console.error('\nFailed to load year_in_review.js:');
console.error(` Error: ${err.message}`);
if (err.stack) {
const stackLines = err.stack.split('\n').slice(1, 6);
console.error(' Stack:\n ', stackLines.join('\n '));
}
process.exit(1);
}
// Validate that required exports exist
if (!globalThis.YearInReviewScenes) {
console.error('\nError: year_in_review.js did not export YearInReviewScenes!');
console.error('Make sure the file ends with:');
console.error(' globalThis.YearInReviewScenes = { TOTAL_FRAMES, mainAnimation, ... }');
process.exit(1);
}
const { mainAnimation, TOTAL_FRAMES } = globalThis.YearInReviewScenes;
if (!mainAnimation || typeof mainAnimation !== 'function') {
console.error('\nError: mainAnimation is not defined or not a function!');
console.error('Check that year_in_review.js exports a valid mainAnimation function.');
process.exit(1);
}
if (!TOTAL_FRAMES || typeof TOTAL_FRAMES !== 'number' || TOTAL_FRAMES <= 0) {
console.error('\nError: TOTAL_FRAMES is not defined or invalid!');
console.error(` Got: ${TOTAL_FRAMES} (type: ${typeof TOTAL_FRAMES})`);
process.exit(1);
}
async function main() {
const engine = new AnimationEngine();
// Handle Ctrl+C immediately so user can exit at any time
process.on('SIGINT', () => {
engine.showCursor();
console.log("\n\nThanks for watching! Happy New Year!\n");
process.exit(0);
});
// Listen for ESC key to exit
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on('data', (key) => {
// ESC key is \x1b (27)
if (key[0] === 0x1b && key.length === 1) {
engine.showCursor();
console.log("\n\nThanks for watching! Happy New Year!\n");
process.exit(0);
}
// Also handle Ctrl+C in raw mode
if (key[0] === 0x03) {
engine.showCursor();
console.log("\n\nThanks for watching! Happy New Year!\n");
process.exit(0);
}
});
}
try {
await engine.playAnimation(mainAnimation, TOTAL_FRAMES, 24);
} catch (err) {
engine.showCursor();
console.error("\n" + "=".repeat(60));
console.error(" ANIMATION PLAYBACK ERROR");
console.error("=".repeat(60));
console.error(`\nError: ${err.message}`);
if (err.stack) {
console.error('\nStack trace:');
const stackLines = err.stack.split('\n').slice(1, 10);
stackLines.forEach(line => console.error(' ' + line.trim()));
}
console.error("\nThis error occurred during animation playback.");
console.error("Tip: Run 'node validate.js' in the thinkback folder to check your animation.");
process.exit(1);
}
// Hold final frame
engine.showCursor();
console.log("\n\n" + "=".repeat(60));
console.log(" That's a wrap on 2025!");
console.log("=".repeat(60) + "\n");
}
main().catch(err => {
console.error("\n" + "=".repeat(60));
console.error(" ERROR RUNNING ANIMATION");
console.error("=".repeat(60));
console.error(`\nError: ${err.message}`);
if (err.stack) {
console.error('\nStack trace:');
const stackLines = err.stack.split('\n').slice(1, 8);
stackLines.forEach(line => console.error(' ' + line.trim()));
}
console.error("\nTip: Run 'node validate.js' in the thinkback folder to check for common issues.");
console.error("");
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,390 @@
/* eslint-disable */
/**
* Helpers Demo Animation
* Showcases all the animation helpers in action
*
* Run with: node helpers_demo.js
*/
import { FrameBuffer, AnimationEngine } from '../ascii_anim.js';
import {
// Transitions
wipeRight, wipeLeft, circleReveal, circleClose, blindsH, blindsV,
diagonalWipe, dissolve, checkerboard,
// Backgrounds
stars, starfield, rain, snow, aurora, waves, fireflies, ripples, staticNoise,
// Text effects
drawTypewriter, drawWaveText, drawGlitchText, drawScatterText,
slideIn, slideOut, drawZoomText, drawFadeInText,
// Particles
confetti, sparkles, burst, bubbles, hearts, musicNotes, leaves, embers, floatingParticles,
// Borders
boxBorder, fullscreenBorder, marchingAnts, growBorder, framedTitle, dividerWithText,
} from '../helpers/index.js';
// Scene definitions with timing
const FPS = 24;
const SCENES = {
// Intro
INTRO_FADE: { start: 0, end: 48 }, // 2s - stars fade in
TITLE: { start: 48, end: 144 }, // 4s - title with effects
// Transition showcase
TRANS_WIPE: { start: 144, end: 216 }, // 3s - wipe transitions
TRANS_CIRCLE: { start: 216, end: 288 }, // 3s - circle reveal
TRANS_BLINDS: { start: 288, end: 360 }, // 3s - blinds effect
// Background showcase
BG_WEATHER: { start: 360, end: 456 }, // 4s - rain/snow
BG_CELESTIAL: { start: 456, end: 552 }, // 4s - aurora/starfield
BG_PATTERNS: { start: 552, end: 624 }, // 3s - waves/ripples
// Text effects showcase
TEXT_TYPE: { start: 624, end: 720 }, // 4s - typewriter
TEXT_WAVE: { start: 720, end: 816 }, // 4s - wave text
TEXT_GLITCH: { start: 816, end: 888 }, // 3s - glitch effect
TEXT_SCATTER: { start: 888, end: 984 }, // 4s - scatter/assemble
// Particles showcase
PART_CONFETTI: { start: 984, end: 1080 }, // 4s - confetti celebration
PART_NATURE: { start: 1080, end: 1176 }, // 4s - leaves/embers
PART_LOVE: { start: 1176, end: 1248 }, // 3s - hearts/music
// Borders showcase
BORDER_GROW: { start: 1248, end: 1344 }, // 4s - growing border
BORDER_MARCH: { start: 1344, end: 1416 }, // 3s - marching ants
// Finale
FINALE: { start: 1416, end: 1560 }, // 6s - everything together
OUTRO: { start: 1560, end: 1632 }, // 3s - fade out
};
const TOTAL_FRAMES = 1632; // ~68 seconds
function getScene(frame) {
for (const [name, timing] of Object.entries(SCENES)) {
if (frame >= timing.start && frame < timing.end) {
return {
name,
progress: (frame - timing.start) / (timing.end - timing.start),
localFrame: frame - timing.start,
duration: timing.end - timing.start
};
}
}
return { name: 'DONE', progress: 1, localFrame: 0, duration: 0 };
}
function easeOut(t) {
return 1 - Math.pow(1 - t, 3);
}
function easeInOut(t) {
return t * t * (3 - 2 * t);
}
function renderFrame(fb, frame) {
const scene = getScene(frame);
const { name, progress: p, localFrame } = scene;
const centerY = Math.floor(fb.height / 2);
const centerX = Math.floor(fb.width / 2);
switch (name) {
// ========== INTRO ==========
case 'INTRO_FADE': {
stars(fb, frame, { density: p * 0.008, twinkle: true });
if (p > 0.5) {
const fadeP = (p - 0.5) * 2;
drawFadeInText(fb, centerY, "✦ HELPERS DEMO ✦", fadeP);
}
break;
}
case 'TITLE': {
starfield(fb, frame, { speed: 0.3, numStars: 40 });
// Animated border
if (p < 0.3) {
growBorder(fb, p / 0.3, { x: 5, y: 3, width: fb.width - 10, height: fb.height - 6, style: 'double' });
} else {
boxBorder(fb, { x: 5, y: 3, width: fb.width - 10, height: fb.height - 6, style: 'double' });
}
drawWaveText(fb, centerY - 2, "Animation Helpers", frame, { amplitude: 1 });
fb.drawCenteredText(centerY + 1, "Transitions · Backgrounds · Text · Particles · Borders");
sparkles(fb, frame, { density: 0.003 });
break;
}
// ========== TRANSITIONS ==========
case 'TRANS_WIPE': {
stars(fb, frame, { density: 0.005 });
fb.drawCenteredText(3, "─── TRANSITIONS ───");
if (p < 0.25) {
fb.drawCenteredText(centerY, "Wipe Right →");
wipeRight(fb, p * 4, '░');
} else if (p < 0.5) {
fb.drawCenteredText(centerY, "← Wipe Left");
wipeLeft(fb, (p - 0.25) * 4, '▒');
} else if (p < 0.75) {
fb.drawCenteredText(centerY, "Diagonal Wipe ↘");
diagonalWipe(fb, (p - 0.5) * 4, 'tl');
} else {
fb.drawCenteredText(centerY, "Dissolve Effect");
dissolve(fb, (p - 0.75) * 4);
}
break;
}
case 'TRANS_CIRCLE': {
fireflies(fb, frame, { count: 6 });
fb.drawCenteredText(3, "─── CIRCLE TRANSITIONS ───");
fb.drawCenteredText(centerY - 1, "Circle Reveal");
fb.drawCenteredText(centerY + 1, "Classic Iris Effect");
if (p < 0.5) {
circleReveal(fb, easeOut(p * 2));
} else {
circleClose(fb, easeOut((p - 0.5) * 2));
}
break;
}
case 'TRANS_BLINDS': {
aurora(fb, frame, { intensity: 0.3 });
fb.drawCenteredText(3, "─── BLINDS TRANSITIONS ───");
if (p < 0.33) {
fb.drawCenteredText(centerY, "Horizontal Blinds");
blindsH(fb, p * 3, 6);
} else if (p < 0.66) {
fb.drawCenteredText(centerY, "Vertical Blinds");
blindsV(fb, (p - 0.33) * 3, 10);
} else {
fb.drawCenteredText(centerY, "Checkerboard Dissolve");
checkerboard(fb, (p - 0.66) * 3, 3);
}
break;
}
// ========== BACKGROUNDS ==========
case 'BG_WEATHER': {
fb.drawCenteredText(2, "─── WEATHER EFFECTS ───");
if (p < 0.5) {
rain(fb, frame, { density: 0.03, speed: 1.5 });
fb.drawCenteredText(centerY, "☔ Rain Effect");
} else {
snow(fb, frame, { density: 0.015 });
fb.drawCenteredText(centerY, "❄ Snow Effect");
}
break;
}
case 'BG_CELESTIAL': {
fb.drawCenteredText(2, "─── CELESTIAL EFFECTS ───");
if (p < 0.5) {
starfield(fb, frame, { speed: 1, numStars: 60 });
fb.drawCenteredText(centerY, "✦ 3D Starfield");
} else {
stars(fb, frame, { density: 0.004 });
aurora(fb, frame, { intensity: 0.6 });
fb.drawCenteredText(centerY, "Northern Lights");
}
break;
}
case 'BG_PATTERNS': {
fb.drawCenteredText(2, "─── PATTERN EFFECTS ───");
if (p < 0.5) {
waves(fb, frame, { amplitude: 3, frequency: 0.08, char: '~', baseY: centerY + 5 });
waves(fb, frame, { amplitude: 2, frequency: 0.1, char: '≈', baseY: centerY + 3 });
fb.drawCenteredText(centerY - 2, "Ocean Waves");
} else {
ripples(fb, frame, { speed: 0.8 });
fb.drawCenteredText(centerY, "Ripple Effect");
}
break;
}
// ========== TEXT EFFECTS ==========
case 'TEXT_TYPE': {
stars(fb, frame, { density: 0.003 });
boxBorder(fb, { x: 10, y: 5, width: fb.width - 20, height: fb.height - 10, style: 'rounded' });
fb.drawCenteredText(7, "─ Typewriter Effect ─");
drawTypewriter(fb, 15, centerY, "Characters appear one by one...", p, { cursor: '▌' });
break;
}
case 'TEXT_WAVE': {
floatingParticles(fb, frame, { count: 8, char: '·' });
fb.drawCenteredText(5, "─ Wave Text Effect ─");
drawWaveText(fb, centerY - 1, "Text that flows like water", frame, { amplitude: 2, frequency: 0.25 });
drawWaveText(fb, centerY + 2, "Each letter bobs up and down", frame, { amplitude: 1.5, frequency: 0.3 });
break;
}
case 'TEXT_GLITCH': {
staticNoise(fb, frame, { density: 0.02 });
fb.drawCenteredText(5, "─ Glitch Effect ─");
drawGlitchText(fb, centerX - 12, centerY - 1, "SYSTEM MALFUNCTION", frame, { intensity: 0.15 });
drawGlitchText(fb, centerX - 10, centerY + 1, "ERROR: REALITY", frame, { intensity: 0.2 });
break;
}
case 'TEXT_SCATTER': {
stars(fb, frame, { density: 0.004 });
fb.drawCenteredText(5, "─ Scatter & Assemble ─");
if (p < 0.5) {
drawScatterText(fb, "LETTERS FLY IN", p * 2, { cy: centerY });
} else {
fb.drawCenteredText(centerY, "LETTERS FLY IN");
sparkles(fb, frame, { density: 0.01 });
}
break;
}
// ========== PARTICLES ==========
case 'PART_CONFETTI': {
fb.drawCenteredText(3, "─── CELEBRATION! ───");
fb.drawCenteredText(centerY, "🎉 CONFETTI & SPARKLES 🎉");
confetti(fb, frame, { count: 25 });
sparkles(fb, frame, { density: 0.008 });
// Burst in middle
if (localFrame > 24 && localFrame < 60) {
burst(fb, frame, { cx: centerX, cy: centerY, count: 16, startFrame: scene.start + 24 });
}
break;
}
case 'PART_NATURE': {
fb.drawCenteredText(3, "─── NATURE PARTICLES ───");
if (p < 0.5) {
leaves(fb, frame, { count: 12 });
fb.drawCenteredText(centerY, "🍂 Falling Leaves");
} else {
embers(fb, frame, { count: 15 });
fb.drawCenteredText(centerY, "🔥 Rising Embers");
}
break;
}
case 'PART_LOVE': {
stars(fb, frame, { density: 0.003 });
fb.drawCenteredText(3, "─── FLOATING SYMBOLS ───");
if (p < 0.5) {
hearts(fb, frame, { count: 10 });
fb.drawCenteredText(centerY, "♥ Floating Hearts ♥");
} else {
musicNotes(fb, frame, { count: 12 });
fb.drawCenteredText(centerY, "♪ Music Notes ♫");
}
break;
}
// ========== BORDERS ==========
case 'BORDER_GROW': {
fireflies(fb, frame, { count: 5 });
fb.drawCenteredText(centerY, "Watch the border grow...");
growBorder(fb, easeInOut(p), {
x: 8, y: 4,
width: fb.width - 16, height: fb.height - 8,
style: 'double'
});
break;
}
case 'BORDER_MARCH': {
stars(fb, frame, { density: 0.003 });
marchingAnts(fb, frame, { x: 5, y: 3, width: fb.width - 10, height: fb.height - 6, speed: 0.5 });
framedTitle(fb, 3, " Marching Ants ", { style: 'single' });
fb.drawCenteredText(centerY, "Animated border pattern");
dividerWithText(fb, fb.height - 4, " borders.js ");
break;
}
// ========== FINALE ==========
case 'FINALE': {
// Layer everything together
starfield(fb, frame, { speed: 0.5, numStars: 30 });
aurora(fb, frame, { intensity: 0.3 });
// Border
if (p < 0.2) {
growBorder(fb, p * 5, { x: 3, y: 2, width: fb.width - 6, height: fb.height - 4, style: 'double' });
} else {
boxBorder(fb, { x: 3, y: 2, width: fb.width - 6, height: fb.height - 4, style: 'double' });
}
// Title
framedTitle(fb, 2, " HELPERS DEMO ", { style: 'double' });
// Animated text
drawWaveText(fb, centerY - 3, "All Effects Combined!", frame, { amplitude: 1 });
// Stats
fb.drawCenteredText(centerY, "69 Effects Available");
fb.drawCenteredText(centerY + 2, "transitions · backgrounds · text · particles · borders");
// Particles
confetti(fb, frame, { count: 10 });
sparkles(fb, frame, { density: 0.004 });
floatingParticles(fb, frame, { count: 6, char: '◇' });
break;
}
case 'OUTRO': {
stars(fb, frame, { density: 0.006 * (1 - p) });
if (p < 0.7) {
fb.drawCenteredText(centerY - 1, "Thanks for watching!");
fb.drawCenteredText(centerY + 1, "import from './helpers/index.js'");
}
// Fade out with circle close
if (p > 0.5) {
circleClose(fb, (p - 0.5) * 2);
}
break;
}
default:
fb.drawCenteredText(centerY, "Demo Complete!");
}
}
// Main execution
async function main() {
const engine = new AnimationEngine();
console.log('Starting Helpers Demo...');
console.log(`Duration: ${Math.round(TOTAL_FRAMES / FPS)}s (${TOTAL_FRAMES} frames @ ${FPS}fps)`);
console.log('Press Ctrl+C to exit\n');
await engine.playAnimation(renderFrame, TOTAL_FRAMES, FPS);
console.log('\nDemo complete!');
}
main().catch(console.error);

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env node
/* eslint-disable */
/**
* Test script for iterating on the Thinkback intro scene
*
* Usage:
* node test_intro.js # Play intro animation
* node test_intro.js --loop # Loop continuously
* node test_intro.js --frame 50 # Show a specific frame
* node test_intro.js --slow # Play at half speed
* node test_intro.js --static # Show static frame (for thumbnail/still)
*/
import { AnimationEngine, FrameBuffer } from '../ascii_anim.js';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load helpers
await import('../helpers/index.js');
// Get helpers from globalThis
const {
SceneManager, stars, sparkles, dissolve,
drawThinkbackIntro, CLAUDE_ORANGE,
} = globalThis;
// Parse command line args
const args = process.argv.slice(2);
const loop = args.includes('--loop');
const slow = args.includes('--slow');
const frameIdx = args.indexOf('--frame');
const singleFrame = frameIdx !== -1 ? parseInt(args[frameIdx + 1], 10) : null;
// Scene definition for intro only
const SCENE_DEFINITIONS = [
{ name: 'thinkback_intro', duration: 7, hold: 2 },
];
// Intro options
const INTRO_OPTIONS = {
year: 2025,
};
const sceneManager = new SceneManager(SCENE_DEFINITIONS);
const TOTAL_FRAMES = sceneManager.getTotalFrames();
function renderIntro(fb, frame) {
const scene = sceneManager.getSceneAt(frame);
if (!scene) return;
// Starfield background
stars(fb, frame, { density: 0.012, twinkle: true });
// Calculate overall progress including hold phase
let p = 0;
if (scene.phase === 'TRANSITION_IN') {
p = scene.transitionProgress * 0.1;
} else if (scene.phase === 'CONTENT') {
p = 0.1 + scene.contentProgress * 0.7;
} else if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
p = 1;
}
// Draw the intro
drawThinkbackIntro(fb, frame, p, INTRO_OPTIONS);
// Add sparkles during hold
if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
sparkles(fb, frame, { density: 0.004 });
}
// Transition out
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
async function main() {
const engine = new AnimationEngine();
const fps = slow ? 12 : 24;
console.log("\n" + "=".repeat(60));
console.log(" THINKBACK INTRO TEST");
console.log("=".repeat(60));
console.log(`\n Total frames: ${TOTAL_FRAMES}`);
console.log(` FPS: ${fps}`);
console.log(` Duration: ${(TOTAL_FRAMES / fps).toFixed(1)}s`);
if (loop) console.log(" Mode: LOOP (Ctrl+C to exit)");
if (singleFrame !== null) console.log(` Showing frame: ${singleFrame}`);
console.log("");
await new Promise(resolve => setTimeout(resolve, 2000));
process.on('SIGINT', () => {
engine.showCursor();
console.log("\n\nExited.\n");
process.exit(0);
});
try {
if (singleFrame !== null) {
// Show a single frame and hold
engine.clearScreen();
engine.hideCursor();
const fb = new FrameBuffer(engine.width, engine.height);
fb.clear();
renderIntro(fb, singleFrame);
fb.blit();
// Show frame info
const scene = sceneManager.getSceneAt(singleFrame);
console.log(`\nFrame ${singleFrame}/${TOTAL_FRAMES} | Phase: ${scene?.phase || 'N/A'}`);
console.log("Press Ctrl+C to exit");
// Hold indefinitely
await new Promise(() => {});
} else {
// Play animation
do {
await engine.playAnimation(renderIntro, TOTAL_FRAMES, fps);
if (loop) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
} while (loop);
}
} catch (err) {
engine.showCursor();
console.error("\nError:", err.message);
if (err.stack) {
console.error(err.stack.split('\n').slice(1, 5).join('\n'));
}
process.exit(1);
}
engine.showCursor();
console.log("\n\nDone.\n");
}
main().catch(err => {
console.error("Error:", err.message);
process.exit(1);
});

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env node
/* eslint-disable */
/**
* Validates thinkback animation files for common issues
* Run with: node validate.js
*/
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let hasErrors = false;
function error(msg) {
console.error(`${msg}`);
hasErrors = true;
}
function success(msg) {
console.log(`${msg}`);
}
async function validate() {
console.log('Validating thinkback animation files...\n');
// Load helpers first
await import('../helpers/index.js');
// Load the animation
await import('../year_in_review.js');
const { YearInReviewScenes } = globalThis;
// Check 1: YearInReviewScenes exists
if (!YearInReviewScenes) {
error('YearInReviewScenes not exported to globalThis');
return;
}
success('YearInReviewScenes exported');
// Check 2: Required exports exist
const requiredExports = ['TOTAL_FRAMES', 'mainAnimation', 'sceneManager'];
for (const key of requiredExports) {
if (!(key in YearInReviewScenes)) {
error(`Missing export: ${key}`);
} else {
success(`Export exists: ${key}`);
}
}
// Check 3: TOTAL_FRAMES is reasonable
const { TOTAL_FRAMES, sceneManager } = YearInReviewScenes;
if (typeof TOTAL_FRAMES !== 'number' || TOTAL_FRAMES < 24) {
error(`TOTAL_FRAMES is invalid: ${TOTAL_FRAMES} (expected >= 24)`);
} else {
success(`TOTAL_FRAMES = ${TOTAL_FRAMES} (${(TOTAL_FRAMES / 24).toFixed(1)}s at 24fps)`);
}
// Check 4: Scene names are defined (not undefined)
if (sceneManager && sceneManager.scenes) {
const undefinedScenes = sceneManager.scenes.filter(s => !s.name);
if (undefinedScenes.length > 0) {
error(`${undefinedScenes.length} scenes have undefined names - did you use 'id' instead of 'name' in SCENE_DEFINITIONS?`);
} else {
success(`All ${sceneManager.scenes.length} scenes have valid names`);
}
// List scene names for reference
console.log('\n Scenes:');
for (const scene of sceneManager.scenes) {
console.log(` - ${scene.name} (${scene.duration}s, frames ${scene.startFrame}-${scene.endFrame})`);
}
}
// Check 5: Test render function with a few frames
const { mainAnimation } = YearInReviewScenes;
if (typeof mainAnimation === 'function') {
// Create a mock framebuffer
const mockFb = {
width: 80,
height: 24,
drawText: () => {},
drawCenteredText: () => {},
drawLargeText: () => {},
drawLargeTextCentered: () => {},
setPixel: () => {},
drawBox: () => {},
drawCircle: () => {},
clear: () => {},
getPixel: () => ' ',
};
const testFrames = [0, Math.floor(TOTAL_FRAMES / 2), TOTAL_FRAMES - 1];
let renderErrors = [];
for (const frame of testFrames) {
try {
mainAnimation(mockFb, frame);
} catch (e) {
renderErrors.push({ frame, error: e.message });
}
}
if (renderErrors.length > 0) {
for (const { frame, error: msg } of renderErrors) {
error(`Render error at frame ${frame}: ${msg}`);
}
} else {
success(`Render function executes without errors`);
}
}
// Check 6: Verify scene transitions cover all frames
if (sceneManager) {
let coveredFrames = 0;
for (const scene of sceneManager.scenes) {
coveredFrames += scene.durationFrames;
}
if (coveredFrames !== TOTAL_FRAMES) {
error(`Scene frames (${coveredFrames}) don't match TOTAL_FRAMES (${TOTAL_FRAMES})`);
} else {
success(`All frames are covered by scenes`);
}
}
console.log('\n' + '='.repeat(50));
if (hasErrors) {
console.log('❌ Validation FAILED - fix errors above');
process.exit(1);
} else {
console.log('✓ Validation PASSED');
process.exit(0);
}
}
validate().catch(err => {
error(`Validation crashed: ${err.message}`);
console.error(err.stack);
process.exit(1);
});

View File

@@ -0,0 +1,399 @@
/* eslint-disable */
/**
* Thinkback - Awards Show Vibe Template
*
* Glamorous awards ceremony style with dramatic reveals.
* Stats presented as award categories with envelope reveals and trophies.
*
* INJECTION POINTS (search for "INJECT:"):
* - STATS object: Fill in all numeric/string values
* - TOP_REPOS array: Fill in top 3 repos with names and commits
*/
// =============================================================================
// HELPER DESTRUCTURING
// =============================================================================
const {
// Scene system
SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
// Backgrounds
stars, gradient,
// Particles
confetti, sparkles, glitter,
// Transitions
dissolve, circleReveal, fade, curtainReveal, spotlightReveal,
// Text effects
drawTypewriterCentered, slideIn, drawZoomText, drawFadeInText,
// Claude branding
drawThinkbackIntro, CLAUDE_ORANGE,
// Awards effects
trophyDisplay, awardBadge, envelopeReveal, categoryTitle,
winnerAnnouncement, applauseMeter, standingOvation, redCarpetBorder,
spotlightText,
} = globalThis;
// =============================================================================
// INJECT: STATS - Fill in all values below
// =============================================================================
const STATS = {
userName: '', // INJECT: User's name
year: 2025,
totalCommits: 0, // INJECT: Total commits
totalSessions: 0, // INJECT: Total sessions
totalMessages: 0, // INJECT: Total messages
repoCount: 0, // INJECT: Number of repos
peakHour: '', // INJECT: e.g., '12am', '3pm'
peakDay: '', // INJECT: e.g., 'Wed', 'Mon'
nightOwlPercent: 0, // INJECT: Percentage (0-100)
earlyBirdPercent: 0, // INJECT: Percentage (0-100)
weekendPercent: 0, // INJECT: Percentage (0-100)
longestStreak: 0, // INJECT: Days
currentStreak: 0, // INJECT: Days
totalActiveDays: 0, // INJECT: Days
marathonDays: 0, // INJECT: Days with 100+ messages
longestSessionMessages: 0, // INJECT: Messages in longest session
firstSessionDate: '', // INJECT: 'YYYY-MM-DD'
busiestWeek: '', // INJECT: e.g., 'Nov 24-30, 2025'
};
// =============================================================================
// INJECT: TOP REPOS - Fill in top 3 repos
// =============================================================================
const TOP_REPOS = [
{ name: '', commits: 0 }, // INJECT: #1 repo name and commits
{ name: '', commits: 0 }, // INJECT: #2 repo name and commits
{ name: '', commits: 0 }, // INJECT: #3 repo name and commits
];
// =============================================================================
// SCENE DEFINITIONS (pre-configured for awards show vibe)
// =============================================================================
const SCENE_DEFINITIONS = [
{ name: 'thinkback_intro', duration: 7, hold: 2 },
{ name: 'opening_ceremony', duration: 7, hold: 2.5 },
{ name: 'best_streak', duration: 8, hold: 3 },
{ name: 'dedication_award', duration: 8, hold: 3 },
{ name: 'lifetime_achievement', duration: 8, hold: 3 },
{ name: 'finale', duration: 6, hold: 2 },
];
const sceneManager = new SceneManager(SCENE_DEFINITIONS);
const TOTAL_FRAMES = sceneManager.getTotalFrames();
// =============================================================================
// USER INTRO
// =============================================================================
const USER_INTRO = {
userName: STATS.userName,
year: STATS.year,
tagline: 'your year with Claude Code',
};
// =============================================================================
// SCENE RENDERERS
// =============================================================================
function renderThinkbackIntro(fb, frame, scene) {
stars(fb, frame, { density: 0.012, twinkle: true });
let p = 0;
if (scene.phase === 'TRANSITION_IN') {
p = scene.transitionProgress * 0.1;
} else if (scene.phase === 'CONTENT') {
p = 0.1 + scene.contentProgress * 0.7;
} else {
p = 1;
}
drawThinkbackIntro(fb, frame, p, USER_INTRO);
if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
sparkles(fb, frame, { density: 0.004 });
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderOpeningCeremony(fb, frame, scene) {
stars(fb, frame, { density: 0.008, twinkle: true });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Curtain reveal effect
if (p < 0.4) {
curtainReveal(fb, p / 0.4);
}
// Red carpet border
if (p > 0.3) {
redCarpetBorder(fb, Math.min(1, (p - 0.3) / 0.3));
}
// Welcome text
if (p > 0.4) {
const textP = Math.min(1, (p - 0.4) / 0.3);
spotlightText(fb, 8, 'Welcome to the', frame, { intensity: textP });
spotlightText(fb, 10, `${STATS.year} Claude Code Awards`, frame, { intensity: textP });
}
// Presenter intro
if (p > 0.7) {
fb.drawCenteredText(15, `Honoring ${STATS.userName}`);
}
// Sparkles
if (p > 0.5) {
glitter(fb, frame, { density: 0.003 });
}
}
if (scene.phase === 'TRANSITION_OUT') {
spotlightReveal(fb, 1 - scene.transitionProgress);
}
}
function renderBestStreak(fb, frame, scene) {
stars(fb, frame, { density: 0.005 });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Category title
if (p > 0.1) {
categoryTitle(fb, 5, 'BEST STREAK AWARD', Math.min(1, (p - 0.1) / 0.2), frame);
}
// Envelope reveal
if (p > 0.3 && p < 0.7) {
const envelopeP = (p - 0.3) / 0.4;
envelopeReveal(fb, `${STATS.longestStreak} Days`, envelopeP, frame, {
y: 12,
});
}
// Winner announcement
if (p > 0.7) {
const winP = Math.min(1, (p - 0.7) / 0.2);
const count = animateCounter(STATS.longestStreak, winP);
fb.drawCenteredText(12, `${count} consecutive days!`);
if (winP > 0.5) {
fb.drawCenteredText(15, 'of showing up');
}
// Trophy
if (winP > 0.3) {
trophyDisplay(fb, Math.floor(fb.width / 2), 20, {
style: 'simple',
label: 'STREAK',
}, winP, frame);
}
// Applause
applauseMeter(fb, fb.height - 5, winP, frame);
}
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderDedicationAward(fb, frame, scene) {
stars(fb, frame, { density: 0.005 });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Category title
if (p > 0.1) {
categoryTitle(fb, 5, 'DEDICATION AWARD', Math.min(1, (p - 0.1) / 0.2), frame);
}
// Stats reveal
if (p > 0.3) {
const reveal = staggeredReveal(3, 0.4);
const stats = [
`${STATS.totalActiveDays} active days`,
`${STATS.totalSessions.toLocaleString()} sessions`,
`${STATS.totalMessages.toLocaleString()} messages`,
];
stats.forEach((stat, i) => {
const itemP = reveal(p - 0.3, i);
if (itemP > 0) {
const y = 11 + i * 2;
awardBadge(fb, Math.floor(fb.width / 2) - 25, y, {
label: stat,
style: i === 0 ? 'gold' : 'silver',
}, itemP);
}
});
}
// Marathon days highlight
if (p > 0.7 && STATS.marathonDays > 0) {
const marathonP = Math.min(1, (p - 0.7) / 0.2);
fb.drawCenteredText(20, `${STATS.marathonDays} marathon days (100+ messages)`);
if (marathonP > 0.5) {
sparkles(fb, frame, { density: 0.005 });
}
}
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderLifetimeAchievement(fb, frame, scene) {
stars(fb, frame, { density: 0.006, twinkle: true });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Grand category title
if (p > 0.1) {
categoryTitle(fb, 4, 'LIFETIME ACHIEVEMENT', Math.min(1, (p - 0.1) / 0.2), frame, {
style: 'grand',
});
}
// Grand trophy
if (p > 0.3) {
const trophyP = Math.min(1, (p - 0.3) / 0.3);
trophyDisplay(fb, Math.floor(fb.width / 2), 10, {
style: 'grand',
label: STATS.year.toString(),
}, trophyP, frame);
}
// Winner name with spotlight
if (p > 0.6) {
const nameP = Math.min(1, (p - 0.6) / 0.2);
winnerAnnouncement(fb, STATS.userName, nameP, frame, {
y: 22,
});
}
// Summary stats
if (p > 0.8) {
fb.drawCenteredText(28, `${STATS.totalCommits} commits across ${STATS.repoCount} projects`);
}
// Celebration
if (p > 0.7) {
glitter(fb, frame, { density: 0.004 });
}
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderFinale(fb, frame, scene) {
stars(fb, frame, { density: 0.01, twinkle: true });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Standing ovation effect
if (p > 0.2) {
standingOvation(fb, frame, { intensity: Math.min(1, (p - 0.2) / 0.3) });
}
// Thank you message
if (p > 0.3) {
spotlightText(fb, Math.floor(fb.height / 2) - 2, 'Thank you for an amazing year!', frame);
}
if (p > 0.6) {
fb.drawCenteredText(Math.floor(fb.height / 2) + 2, 'See you at next year\'s ceremony!');
}
// Confetti celebration
if (p > 0.4) {
confetti(fb, frame, { count: 20 });
}
}
if (scene.phase === 'TRANSITION_OUT') {
const fadeP = 1 - scene.transitionProgress;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
if (Math.random() < fadeP * 0.5) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
}
// =============================================================================
// SCENE MAPPING
// =============================================================================
const SCENE_RENDERERS = {
thinkback_intro: renderThinkbackIntro,
opening_ceremony: renderOpeningCeremony,
best_streak: renderBestStreak,
dedication_award: renderDedicationAward,
lifetime_achievement: renderLifetimeAchievement,
finale: renderFinale,
};
// =============================================================================
// MAIN ANIMATION
// =============================================================================
function mainAnimation(fb, frame) {
const scene = sceneManager.getSceneAt(frame);
if (!scene) {
fb.drawCenteredText(Math.floor(fb.height / 2), 'Animation complete');
return;
}
const renderer = SCENE_RENDERERS[scene.name];
if (renderer) {
renderer(fb, frame, scene);
}
}
function getSceneName(frame) {
const scene = sceneManager.getSceneAt(frame);
if (!scene) return 'Complete';
const names = {
thinkback_intro: 'Think Back',
opening_ceremony: 'Opening',
best_streak: 'Best Streak',
dedication_award: 'Dedication',
lifetime_achievement: 'Lifetime',
finale: 'Finale',
};
return names[scene.name] || scene.name;
}
// =============================================================================
// EXPORTS
// =============================================================================
globalThis.YearInReviewScenes = {
TOTAL_FRAMES,
mainAnimation,
getSceneName,
sceneManager,
};

View File

@@ -0,0 +1,300 @@
/* eslint-disable */
/**
* Thinkback - Cozy Vibe Template
*
* Warm, gentle, and comforting. Like a bedtime story.
* Stats are revealed softly with typewriter effects and gentle backgrounds.
*
* INJECTION POINTS (search for "INJECT:"):
* - STATS object: Fill in all numeric/string values
*/
// =============================================================================
// HELPER DESTRUCTURING
// =============================================================================
const {
// Scene system
SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
// Backgrounds
stars, fireflies, dust,
// Particles
floatingParticles, sparkles,
// Transitions
dissolve, circleReveal, fade,
// Text effects
drawTypewriterCentered, drawFadeInText,
// Claude branding
drawThinkbackIntro, CLAUDE_ORANGE,
} = globalThis;
// =============================================================================
// INJECT: STATS - Fill in all values below
// =============================================================================
const STATS = {
userName: '', // INJECT: User's name
year: 2025,
totalCommits: 0, // INJECT: Total commits
totalSessions: 0, // INJECT: Total sessions
totalMessages: 0, // INJECT: Total messages
repoCount: 0, // INJECT: Number of repos
peakHour: '', // INJECT: e.g., '12am', '3pm'
peakDay: '', // INJECT: e.g., 'Wed', 'Mon'
nightOwlPercent: 0, // INJECT: Percentage (0-100)
earlyBirdPercent: 0, // INJECT: Percentage (0-100)
weekendPercent: 0, // INJECT: Percentage (0-100)
longestStreak: 0, // INJECT: Days
currentStreak: 0, // INJECT: Days
totalActiveDays: 0, // INJECT: Days
marathonDays: 0, // INJECT: Days with 100+ messages
longestSessionMessages: 0, // INJECT: Messages in longest session
firstSessionDate: '', // INJECT: 'YYYY-MM-DD'
busiestWeek: '', // INJECT: e.g., 'Nov 24-30, 2025'
};
// =============================================================================
// SCENE DEFINITIONS (pre-configured for cozy vibe)
// =============================================================================
const SCENE_DEFINITIONS = [
{ name: 'thinkback_intro', duration: 7, hold: 2 },
{ name: 'your_rhythm', duration: 8, hold: 3 },
{ name: 'the_streak', duration: 7, hold: 2.5 },
{ name: 'quiet_moments', duration: 6, hold: 2 },
{ name: 'closing', duration: 5, hold: 2 },
];
const sceneManager = new SceneManager(SCENE_DEFINITIONS);
const TOTAL_FRAMES = sceneManager.getTotalFrames();
// =============================================================================
// USER INTRO
// =============================================================================
const USER_INTRO = {
userName: STATS.userName,
year: STATS.year,
tagline: 'your year with Claude Code',
};
// =============================================================================
// SCENE RENDERERS
// =============================================================================
function renderThinkbackIntro(fb, frame, scene) {
stars(fb, frame, { density: 0.012, twinkle: true });
let p = 0;
if (scene.phase === 'TRANSITION_IN') {
p = scene.transitionProgress * 0.1;
} else if (scene.phase === 'CONTENT') {
p = 0.1 + scene.contentProgress * 0.7;
} else {
p = 1;
}
drawThinkbackIntro(fb, frame, p, USER_INTRO);
if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
sparkles(fb, frame, { density: 0.004 });
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderYourRhythm(fb, frame, scene) {
stars(fb, frame, { density: 0.006, twinkle: true });
floatingParticles(fb, frame, { count: 8, char: '◇', speed: 0.5 });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Title
if (p > 0.1) {
drawTypewriterCentered(fb, 5, '~ Your Rhythm ~', Math.min(1, (p - 0.1) / 0.2));
}
// Time stats - dynamically built based on available data
const reveal = staggeredReveal(4, 0.4);
const timeStats = [
`You were most active at ${STATS.peakHour}`,
`${STATS.peakDay}s were your favorite coding day`,
STATS.nightOwlPercent > 15 ? `Night owl: ${STATS.nightOwlPercent.toFixed(1)}% of sessions after 10pm` : null,
STATS.earlyBirdPercent > 10 ? `Early bird: ${STATS.earlyBirdPercent.toFixed(1)}% of sessions before 8am` : null,
].filter(Boolean);
timeStats.forEach((stat, i) => {
const itemP = reveal(p, i);
if (itemP > 0) {
const y = 10 + i * 2;
const visibleChars = Math.floor(stat.length * itemP);
fb.drawCenteredText(y, stat.slice(0, visibleChars));
}
});
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderTheStreak(fb, frame, scene) {
stars(fb, frame, { density: 0.004 });
fireflies(fb, frame, { count: 6 });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
if (p > 0.1) {
const titleP = Math.min(1, (p - 0.1) / 0.2);
drawTypewriterCentered(fb, 5, '~ The Streak ~', titleP);
}
if (p > 0.3 && STATS.longestStreak > 0) {
const streakP = Math.min(1, (p - 0.3) / 0.3);
const streakCount = animateCounter(STATS.longestStreak, streakP);
fb.drawCenteredText(9, `${streakCount} days in a row`);
if (streakP > 0.5) {
fb.drawCenteredText(11, 'you showed up');
}
}
if (p > 0.6) {
const msgP = Math.min(1, (p - 0.6) / 0.3);
const msg = 'take a moment to appreciate that';
const visibleChars = Math.floor(msg.length * msgP);
fb.drawCenteredText(15, msg.slice(0, visibleChars));
}
if (STATS.currentStreak > 0 && p > 0.8) {
fb.drawCenteredText(18, `(current streak: ${STATS.currentStreak} days)`);
}
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderQuietMoments(fb, frame, scene) {
stars(fb, frame, { density: 0.008, twinkle: true });
dust(fb, frame, { density: 0.002 });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
if (p > 0.1) {
drawTypewriterCentered(fb, 5, '~ Quiet Moments ~', Math.min(1, (p - 0.1) / 0.2));
}
const reveal = staggeredReveal(3, 0.5);
const moments = [
`${STATS.totalActiveDays} days you chose to build`,
`${STATS.totalMessages.toLocaleString()} messages exchanged`,
`across ${STATS.repoCount} projects`,
];
moments.forEach((moment, i) => {
const itemP = reveal(p, i);
if (itemP > 0) {
const y = 10 + i * 2;
fb.drawCenteredText(y, moment, 0, itemP < 1 ? '#666666' : undefined);
}
});
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderClosing(fb, frame, scene) {
stars(fb, frame, { density: 0.01, twinkle: true });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
if (p > 0.2) {
fb.drawCenteredText(Math.floor(fb.height / 2) - 3, 'Thank you for this year');
}
if (p > 0.5) {
fb.drawCenteredText(Math.floor(fb.height / 2) + 1, 'Rest well. You\'ve earned it.');
}
sparkles(fb, frame, { density: 0.005 });
}
if (scene.phase === 'TRANSITION_OUT') {
const fadeP = 1 - scene.transitionProgress;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
if (Math.random() < fadeP * 0.5) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
}
// =============================================================================
// SCENE MAPPING
// =============================================================================
const SCENE_RENDERERS = {
thinkback_intro: renderThinkbackIntro,
your_rhythm: renderYourRhythm,
the_streak: renderTheStreak,
quiet_moments: renderQuietMoments,
closing: renderClosing,
};
// =============================================================================
// MAIN ANIMATION
// =============================================================================
function mainAnimation(fb, frame) {
const scene = sceneManager.getSceneAt(frame);
if (!scene) {
fb.drawCenteredText(Math.floor(fb.height / 2), 'Animation complete');
return;
}
const renderer = SCENE_RENDERERS[scene.name];
if (renderer) {
renderer(fb, frame, scene);
}
}
function getSceneName(frame) {
const scene = sceneManager.getSceneAt(frame);
if (!scene) return 'Complete';
const names = {
thinkback_intro: 'Think Back',
your_rhythm: 'Your Rhythm',
the_streak: 'The Streak',
quiet_moments: 'Quiet Moments',
closing: 'Closing',
};
return names[scene.name] || scene.name;
}
// =============================================================================
// EXPORTS
// =============================================================================
globalThis.YearInReviewScenes = {
TOTAL_FRAMES,
mainAnimation,
getSceneName,
sceneManager,
};

View File

@@ -0,0 +1,412 @@
/* eslint-disable */
/**
* Thinkback - Morning News Vibe Template
*
* Upbeat, professional news broadcast style.
* Stats revealed as "breaking news" with tickers and dramatic counters.
*
* INJECTION POINTS (search for "INJECT:"):
* - STATS object: Fill in all numeric/string values
* - TOP_REPOS array: Fill in top 3 repos with names and commits
*/
// =============================================================================
// HELPER DESTRUCTURING
// =============================================================================
const {
// Scene system
SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
// Backgrounds
stars, gradient,
// Particles
confetti, sparkles, burst,
// Transitions
dissolve, wipeRight, wipeLeft, wipeDown, blindsH,
// Text effects
drawTypewriterCentered, slideIn,
// Claude branding
drawThinkbackIntro, CLAUDE_ORANGE,
// News effects
lowerThird, tickerTape, breakingBanner, liveIndicator,
segmentTitle, statCounter, forecastBar, splitWipe,
headlineCrawl, countdownReveal,
} = globalThis;
// =============================================================================
// INJECT: STATS - Fill in all values below
// =============================================================================
const STATS = {
userName: '', // INJECT: User's name
year: 2025,
totalCommits: 0, // INJECT: Total commits
totalSessions: 0, // INJECT: Total sessions
totalMessages: 0, // INJECT: Total messages
repoCount: 0, // INJECT: Number of repos
peakHour: '', // INJECT: e.g., '12am', '3pm'
peakDay: '', // INJECT: e.g., 'Wed', 'Mon'
nightOwlPercent: 0, // INJECT: Percentage (0-100)
earlyBirdPercent: 0, // INJECT: Percentage (0-100)
weekendPercent: 0, // INJECT: Percentage (0-100)
longestStreak: 0, // INJECT: Days
currentStreak: 0, // INJECT: Days
totalActiveDays: 0, // INJECT: Days
marathonDays: 0, // INJECT: Days with 100+ messages
longestSessionMessages: 0, // INJECT: Messages in longest session
firstSessionDate: '', // INJECT: 'YYYY-MM-DD'
busiestWeek: '', // INJECT: e.g., 'Nov 24-30, 2025'
};
// =============================================================================
// INJECT: TOP REPOS - Fill in top 3 repos
// =============================================================================
const TOP_REPOS = [
{ name: '', commits: 0 }, // INJECT: #1 repo name and commits
{ name: '', commits: 0 }, // INJECT: #2 repo name and commits
{ name: '', commits: 0 }, // INJECT: #3 repo name and commits
];
// =============================================================================
// SCENE DEFINITIONS (pre-configured for morning news vibe)
// =============================================================================
const SCENE_DEFINITIONS = [
{ name: 'thinkback_intro', duration: 7, hold: 2 },
{ name: 'breaking_news', duration: 8, hold: 2.5 },
{ name: 'headline_stats', duration: 8, hold: 3 },
{ name: 'coding_forecast', duration: 7, hold: 2.5 },
{ name: 'top_stories', duration: 7, hold: 2.5 },
{ name: 'closing', duration: 5, hold: 2 },
];
const sceneManager = new SceneManager(SCENE_DEFINITIONS);
const TOTAL_FRAMES = sceneManager.getTotalFrames();
// =============================================================================
// USER INTRO
// =============================================================================
const USER_INTRO = {
userName: STATS.userName,
year: STATS.year,
tagline: 'your year with Claude Code',
};
// =============================================================================
// SCENE RENDERERS
// =============================================================================
function renderThinkbackIntro(fb, frame, scene) {
stars(fb, frame, { density: 0.012, twinkle: true });
let p = 0;
if (scene.phase === 'TRANSITION_IN') {
p = scene.transitionProgress * 0.1;
} else if (scene.phase === 'CONTENT') {
p = 0.1 + scene.contentProgress * 0.7;
} else {
p = 1;
}
drawThinkbackIntro(fb, frame, p, USER_INTRO);
if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
sparkles(fb, frame, { density: 0.004 });
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderBreakingNews(fb, frame, scene) {
// Dark gradient background
gradient(fb, { direction: 'vertical', invert: true });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Breaking banner at top
if (p > 0.1) {
breakingBanner(fb, 3, 'BREAKING NEWS', frame, {
flash: true,
width: 50,
});
}
// Live indicator
if (p > 0.2) {
liveIndicator(fb, 70, 3, frame, { blink: true });
}
// Main headline - biggest stat
if (p > 0.3) {
const headlineP = Math.min(1, (p - 0.3) / 0.3);
segmentTitle(fb, 10, `${STATS.userName}'s Year in Review`, headlineP, {
style: 'double',
});
}
// Big stat counter
if (p > 0.5) {
const statP = Math.min(1, (p - 0.5) / 0.3);
const count = animateCounter(STATS.totalMessages, statP);
fb.drawCenteredText(15, `${count.toLocaleString()}`);
if (statP > 0.5) {
fb.drawCenteredText(17, 'messages exchanged');
}
}
// Ticker at bottom
if (p > 0.4) {
const tickerItems = [
`${STATS.totalCommits} commits`,
`${STATS.repoCount} projects`,
`${STATS.totalActiveDays} active days`,
`Peak hour: ${STATS.peakHour}`,
];
tickerTape(fb, fb.height - 3, tickerItems, frame, { speed: 0.5 });
}
}
if (scene.phase === 'TRANSITION_OUT') {
wipeRight(fb, scene.transitionProgress, '░');
}
}
function renderHeadlineStats(fb, frame, scene) {
gradient(fb, { direction: 'vertical', invert: true });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Section header
if (p > 0.1) {
segmentTitle(fb, 3, 'TOP HEADLINES', Math.min(1, (p - 0.1) / 0.2), {
style: 'single',
});
}
// Stats as lower thirds
const reveal = staggeredReveal(4, 0.3);
const headlines = [
{ label: 'TOTAL SESSIONS', value: STATS.totalSessions.toLocaleString() },
{ label: 'LONGEST STREAK', value: `${STATS.longestStreak} days` },
{ label: 'MARATHON DAYS', value: STATS.marathonDays.toString() },
{ label: 'BUSIEST WEEK', value: STATS.busiestWeek },
];
headlines.forEach((item, i) => {
const itemP = reveal(p, i);
if (itemP > 0) {
const y = 8 + i * 4;
lowerThird(fb, y, item.label, item.value, itemP, {
width: 50,
centered: true,
});
}
});
// Ticker
if (p > 0.3) {
tickerTape(fb, fb.height - 3, [
'More stats after the break...',
`First session: ${STATS.firstSessionDate}`,
], frame);
}
}
if (scene.phase === 'TRANSITION_OUT') {
blindsH(fb, scene.transitionProgress, 6);
}
}
function renderCodingForecast(fb, frame, scene) {
gradient(fb, { direction: 'vertical', invert: true });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Section header
if (p > 0.1) {
segmentTitle(fb, 3, 'CODING FORECAST', Math.min(1, (p - 0.1) / 0.2));
}
// Time patterns as forecast bars
const reveal = staggeredReveal(3, 0.4);
if (p > 0.3) {
const forecastP = reveal(p, 0);
if (forecastP > 0) {
fb.drawCenteredText(9, `Peak activity: ${STATS.peakHour} on ${STATS.peakDay}s`);
}
}
if (p > 0.4) {
const nightP = reveal(p, 1);
if (nightP > 0 && STATS.nightOwlPercent > 5) {
forecastBar(fb, 15, 13, 'Night Owl', STATS.nightOwlPercent, 40, nightP, {
char: '█',
});
}
}
if (p > 0.5) {
const earlyP = reveal(p, 2);
if (earlyP > 0 && STATS.earlyBirdPercent > 5) {
forecastBar(fb, 15, 16, 'Early Bird', STATS.earlyBirdPercent, 40, earlyP, {
char: '█',
});
}
}
if (p > 0.6) {
const weekendP = Math.min(1, (p - 0.6) / 0.3);
if (weekendP > 0) {
forecastBar(fb, 15, 19, 'Weekend', STATS.weekendPercent, 40, weekendP, {
char: '█',
});
}
}
}
if (scene.phase === 'TRANSITION_OUT') {
wipeDown(fb, scene.transitionProgress, '░');
}
}
function renderTopStories(fb, frame, scene) {
gradient(fb, { direction: 'vertical', invert: true });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Section header
if (p > 0.1) {
segmentTitle(fb, 3, 'TOP REPOSITORIES', Math.min(1, (p - 0.1) / 0.2));
}
// Top repos as headlines
const reveal = staggeredReveal(3, 0.4);
TOP_REPOS.forEach((repo, i) => {
if (repo.name) {
const itemP = reveal(p, i);
if (itemP > 0) {
const y = 9 + i * 4;
const rank = i + 1;
lowerThird(fb, y, `#${rank}`, `${repo.name} (${repo.commits} commits)`, itemP, {
width: 55,
centered: true,
});
}
}
});
// Celebration sparkles when fully revealed
if (p > 0.8) {
sparkles(fb, frame, { density: 0.003 });
}
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderClosing(fb, frame, scene) {
gradient(fb, { direction: 'vertical', invert: true });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
if (p > 0.2) {
segmentTitle(fb, 8, 'THAT\'S A WRAP', Math.min(1, (p - 0.2) / 0.3));
}
if (p > 0.4) {
fb.drawCenteredText(14, `Thanks for tuning in, ${STATS.userName}!`);
}
if (p > 0.6) {
fb.drawCenteredText(17, 'See you next year!');
}
// Celebration
if (p > 0.5) {
confetti(fb, frame, { count: 15 });
}
}
if (scene.phase === 'TRANSITION_OUT') {
const fadeP = 1 - scene.transitionProgress;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
if (Math.random() < fadeP * 0.5) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
}
// =============================================================================
// SCENE MAPPING
// =============================================================================
const SCENE_RENDERERS = {
thinkback_intro: renderThinkbackIntro,
breaking_news: renderBreakingNews,
headline_stats: renderHeadlineStats,
coding_forecast: renderCodingForecast,
top_stories: renderTopStories,
closing: renderClosing,
};
// =============================================================================
// MAIN ANIMATION
// =============================================================================
function mainAnimation(fb, frame) {
const scene = sceneManager.getSceneAt(frame);
if (!scene) {
fb.drawCenteredText(Math.floor(fb.height / 2), 'Animation complete');
return;
}
const renderer = SCENE_RENDERERS[scene.name];
if (renderer) {
renderer(fb, frame, scene);
}
}
function getSceneName(frame) {
const scene = sceneManager.getSceneAt(frame);
if (!scene) return 'Complete';
const names = {
thinkback_intro: 'Think Back',
breaking_news: 'Breaking News',
headline_stats: 'Headlines',
coding_forecast: 'Forecast',
top_stories: 'Top Stories',
closing: 'Closing',
};
return names[scene.name] || scene.name;
}
// =============================================================================
// EXPORTS
// =============================================================================
globalThis.YearInReviewScenes = {
TOTAL_FRAMES,
mainAnimation,
getSceneName,
sceneManager,
};

View File

@@ -0,0 +1,443 @@
/* eslint-disable */
/**
* Thinkback - RPG Quest Vibe Template
*
* Epic RPG adventure experience with quest logs, level-ups, and legendary achievements.
* Stats presented as XP gained, skills acquired, and character class reveal.
*
* INJECTION POINTS (search for "INJECT:"):
* - STATS object: Fill in all numeric/string values
* - TOP_REPOS array: Fill in top 3 repos with names and commits
* - CHARACTER_CLASS: Fill in based on user's work patterns
*/
// =============================================================================
// HELPER DESTRUCTURING
// =============================================================================
const {
// Scene system
SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
// Backgrounds
stars, gradient,
// Particles
confetti, sparkles, burst,
// Transitions
dissolve, pixelate, blindsH, fade,
// Text effects
drawTypewriterCentered, slideIn, drawZoomText,
// Claude branding
drawThinkbackIntro, CLAUDE_ORANGE,
// RPG-specific effects
titleScreen, textBox, classSelect, questCard, questBanner,
xpBar, levelUp, statsPanel, creditsRoll, victoryFanfare,
} = globalThis;
// =============================================================================
// INJECT: STATS - Fill in all values below
// =============================================================================
const STATS = {
userName: '', // INJECT: User's name
year: 2025,
totalCommits: 0, // INJECT: Total commits
totalSessions: 0, // INJECT: Total sessions
totalMessages: 0, // INJECT: Total messages
repoCount: 0, // INJECT: Number of repos
peakHour: '', // INJECT: e.g., '12am', '3pm'
peakDay: '', // INJECT: e.g., 'Wed', 'Mon'
nightOwlPercent: 0, // INJECT: Percentage (0-100)
earlyBirdPercent: 0, // INJECT: Percentage (0-100)
weekendPercent: 0, // INJECT: Percentage (0-100)
longestStreak: 0, // INJECT: Days
currentStreak: 0, // INJECT: Days
totalActiveDays: 0, // INJECT: Days
marathonDays: 0, // INJECT: Days with 100+ messages
longestSessionMessages: 0, // INJECT: Messages in longest session
firstSessionDate: '', // INJECT: 'YYYY-MM-DD'
busiestWeek: '', // INJECT: e.g., 'Nov 24-30, 2025'
};
// =============================================================================
// INJECT: TOP REPOS - Fill in top 3 repos
// =============================================================================
const TOP_REPOS = [
{ name: '', commits: 0 }, // INJECT: #1 repo name and commits
{ name: '', commits: 0 }, // INJECT: #2 repo name and commits
{ name: '', commits: 0 }, // INJECT: #3 repo name and commits
];
// =============================================================================
// INJECT: CHARACTER CLASS - Based on user's work patterns
// =============================================================================
// Choose based on user's activity patterns:
// - 'BUG_SLAYER': Lots of fixes
// - 'FEATURE_CRAFTER': New functionality focused
// - 'DOCS_WIZARD': Documentation heavy
// - 'REFACTOR_KNIGHT': Code improvements
// - 'FULL_STACK_PALADIN': Balanced across all areas
// - 'SPEED_DEMON': High commit velocity
// - 'DEEP_DELVER': Long, complex sessions
const CHARACTER_CLASS = ''; // INJECT: e.g., 'FEATURE_CRAFTER'
const CLASS_DESCRIPTION = ''; // INJECT: e.g., 'A builder of new worlds'
// =============================================================================
// SCENE DEFINITIONS (pre-configured for RPG quest vibe)
// =============================================================================
const SCENE_DEFINITIONS = [
{ name: 'thinkback_intro', duration: 7, hold: 2 },
{ name: 'title_screen', duration: 6, hold: 2 },
{ name: 'class_reveal', duration: 8, hold: 3 },
{ name: 'quest_log', duration: 8, hold: 3 },
{ name: 'level_up', duration: 7, hold: 2.5 },
{ name: 'credits', duration: 6, hold: 2 },
];
const sceneManager = new SceneManager(SCENE_DEFINITIONS);
const TOTAL_FRAMES = sceneManager.getTotalFrames();
// =============================================================================
// USER INTRO
// =============================================================================
const USER_INTRO = {
userName: STATS.userName,
year: STATS.year,
tagline: 'your year with Claude Code',
};
// =============================================================================
// DERIVED STATS (computed from STATS for RPG presentation)
// =============================================================================
// Calculate "level" based on total activity
function calculateLevel() {
const xp = STATS.totalCommits * 10 + STATS.totalMessages + STATS.totalActiveDays * 50;
return Math.min(99, Math.floor(Math.log2(xp / 100) + 1)) || 1;
}
// Generate character stats based on activity patterns
function getCharacterStats() {
const stats = {};
// STR = commit intensity
stats.STR = Math.min(10, Math.floor(STATS.totalCommits / 100) + 3);
// DEX = session frequency
stats.DEX = Math.min(10, Math.floor(STATS.totalSessions / 50) + 2);
// INT = message depth
stats.INT = Math.min(10, Math.floor(STATS.totalMessages / 500) + 3);
// WIS = consistency (streak-based)
stats.WIS = Math.min(10, Math.floor(STATS.longestStreak / 10) + 2);
return stats;
}
// Generate character traits based on patterns
function getCharacterTraits() {
const traits = [];
if (STATS.nightOwlPercent > 30) traits.push('Night Owl');
if (STATS.earlyBirdPercent > 20) traits.push('Early Riser');
if (STATS.weekendPercent > 30) traits.push('Weekend Warrior');
if (STATS.longestStreak > 14) traits.push('Persistent');
if (STATS.marathonDays > 5) traits.push('Enduring');
if (STATS.totalCommits > 500) traits.push('Prolific');
// Return top 3 traits
return traits.slice(0, 3);
}
// =============================================================================
// SCENE RENDERERS
// =============================================================================
function renderThinkbackIntro(fb, frame, scene) {
stars(fb, frame, { density: 0.012, twinkle: true });
let p = 0;
if (scene.phase === 'TRANSITION_IN') {
p = scene.transitionProgress * 0.1;
} else if (scene.phase === 'CONTENT') {
p = 0.1 + scene.contentProgress * 0.7;
} else {
p = 1;
}
drawThinkbackIntro(fb, frame, p, USER_INTRO);
if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
sparkles(fb, frame, { density: 0.004 });
}
if (scene.phase === 'TRANSITION_OUT') {
pixelate(fb, scene.transitionProgress, 8);
}
}
function renderTitleScreen(fb, frame, scene) {
stars(fb, frame, { density: 0.008, twinkle: true });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
titleScreen(fb, {
title: 'YEAR IN CODE',
subtitle: STATS.year.toString(),
prompt: 'PRESS START',
}, p, frame);
}
if (scene.phase === 'TRANSITION_OUT') {
pixelate(fb, scene.transitionProgress, 6);
}
}
function renderClassReveal(fb, frame, scene) {
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Build up text
if (p < 0.3) {
textBox(fb, 18, 'Your deeds have defined you...', p / 0.3, frame, {
width: 45,
style: 'rpg',
});
}
// Class reveal
if (p >= 0.3) {
const classP = (p - 0.3) / 0.7;
const className = CHARACTER_CLASS.replace(/_/g, ' ') || 'ADVENTURER';
const description = CLASS_DESCRIPTION || 'A brave soul';
classSelect(fb, {
className,
description,
stats: getCharacterStats(),
traits: getCharacterTraits(),
}, classP, frame, {
y: 4,
showSprite: true,
});
if (classP > 0.5) {
sparkles(fb, frame, { density: 0.004 });
}
}
}
if (scene.phase === 'TRANSITION_OUT') {
blindsH(fb, scene.transitionProgress, 6);
}
}
function renderQuestLog(fb, frame, scene) {
gradient(fb, { direction: 'vertical', chars: [' ', '░'] });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Quest log header
if (p < 0.15) {
questBanner(fb, 'QUEST LOG', p / 0.15, frame, { y: 2, style: 'simple' });
}
// Cycle through completed quests (repos)
if (p >= 0.15) {
const questP = (p - 0.15) / 0.85;
const validRepos = TOP_REPOS.filter(r => r.name);
const numQuests = validRepos.length || 1;
const questIdx = Math.min(numQuests - 1, Math.floor(questP * numQuests));
const questLocalP = (questP * numQuests) % 1;
if (validRepos.length > 0) {
const repo = validRepos[questIdx];
// Quest complete banner
if (questLocalP < 0.25) {
questBanner(fb, 'QUEST COMPLETE', questLocalP / 0.25, frame, {
y: 3,
style: 'fanfare',
});
}
// Quest card (simplified - no body/description)
if (questLocalP >= 0.25) {
const cardP = (questLocalP - 0.25) / 0.75;
questCard(fb, {
name: repo.name,
commits: repo.commits,
rank: questIdx + 1,
}, cardP, frame, {
y: 5,
width: 50,
showRewards: true,
});
// Victory sparkles
if (cardP > 0.4) {
sparkles(fb, frame, { density: 0.005 });
}
}
// Progress indicator
if (numQuests > 1) {
const indicator = `${questIdx + 1}/${numQuests}`;
fb.drawText(fb.width - indicator.length - 2, 2, indicator);
}
} else {
// Fallback if no repos
fb.drawCenteredText(12, 'Your quests await...');
}
}
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderLevelUp(fb, frame, scene) {
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
// Dramatic build
if (p < 0.3) {
textBox(fb, 10, 'Your journey has made you stronger...', p / 0.3, frame, {
width: 45,
style: 'rpg',
});
}
// Level up reveal
if (p >= 0.3) {
const lvlP = (p - 0.3) / 0.7;
levelUp(fb, {
level: calculateLevel(),
stats: [
{ name: 'COMMITS', gained: `+${STATS.totalCommits.toLocaleString()}` },
{ name: 'QUESTS', gained: `+${STATS.repoCount}` },
{ name: 'ACTIVE DAYS', gained: `+${STATS.totalActiveDays}` },
],
}, lvlP, frame, {
y: 6,
});
// Celebration
if (lvlP > 0.3) {
victoryFanfare(fb, frame, { intensity: lvlP });
}
}
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderCredits(fb, frame, scene) {
stars(fb, frame, { density: 0.006, twinkle: true });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
creditsRoll(fb, [
{ type: 'header', text: 'ADVENTURE COMPLETE' },
{ type: 'spacer' },
{ type: 'stat', label: 'Total XP', value: (STATS.totalCommits * 10 + STATS.totalMessages).toLocaleString() },
{ type: 'stat', label: 'Quests Completed', value: STATS.repoCount.toString() },
{ type: 'stat', label: 'Days Adventured', value: STATS.totalActiveDays.toString() },
{ type: 'stat', label: 'Longest Streak', value: `${STATS.longestStreak} days` },
{ type: 'spacer' },
{ type: 'spacer' },
{ type: 'text', text: 'Your adventure continues...' },
{ type: 'spacer' },
{ type: 'header', text: (STATS.year + 1).toString() },
], p, frame, {
speed: 0.4,
});
}
if (scene.phase === 'TRANSITION_OUT') {
const fadeP = 1 - scene.transitionProgress;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
if (Math.random() < fadeP * 0.5) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
}
// =============================================================================
// SCENE MAPPING
// =============================================================================
const SCENE_RENDERERS = {
thinkback_intro: renderThinkbackIntro,
title_screen: renderTitleScreen,
class_reveal: renderClassReveal,
quest_log: renderQuestLog,
level_up: renderLevelUp,
credits: renderCredits,
};
// =============================================================================
// MAIN ANIMATION
// =============================================================================
function mainAnimation(fb, frame) {
const scene = sceneManager.getSceneAt(frame);
if (!scene) {
fb.drawCenteredText(Math.floor(fb.height / 2), 'Animation complete');
return;
}
const renderer = SCENE_RENDERERS[scene.name];
if (renderer) {
renderer(fb, frame, scene);
}
}
function getSceneName(frame) {
const scene = sceneManager.getSceneAt(frame);
if (!scene) return 'Complete';
const names = {
thinkback_intro: 'Think Back',
title_screen: 'Title Screen',
class_reveal: 'Class Reveal',
quest_log: 'Quest Log',
level_up: 'Level Up',
credits: 'Credits',
};
return names[scene.name] || scene.name;
}
// =============================================================================
// EXPORTS
// =============================================================================
globalThis.YearInReviewScenes = {
TOTAL_FRAMES,
mainAnimation,
getSceneName,
sceneManager,
};

View File

@@ -0,0 +1,440 @@
# Awards Show Vibe Instructions
Generate a glamorous, celebratory awards ceremony experience. Think red carpet energy, dramatic envelope reveals, standing ovations, and acceptance speeches.
Imagine you are the host of a prestigious awards ceremony honoring the year's greatest developer achievements.
## Tone Guidelines
- **Celebratory and glamorous**: This is their night to shine
- **Dramatic tension**: Build suspense before reveals
- **Reverent**: Treat achievements as genuinely impressive accomplishments
- **Warm**: Personal touches, like the host knows the honoree
## Pacing
- Slow builds to dramatic reveals ("And the award goes to...")
- Pause for applause moments after big announcements
- Quick montage cuts for recap sections
- Lingering on acceptance speech moments (project spotlights)
## Segment Ideas
Structure the thinkback like an awards ceremony:
- **RED CARPET INTRO**: Welcome, set the scene, tease what's coming
- **OPENING MONTAGE**: Quick highlights reel of the year
- **TECHNICAL ACHIEVEMENT**: Stats-heavy awards (commits, PRs, lines)
- **BEST SUPPORTING**: Secondary projects, contributions
- **BEST PROJECT**: **THE STAR OF THE SHOW** - Top 3 projects get full spotlight treatment, each with their own acceptance speech moment
- **LIFETIME ACHIEVEMENT**: Overall year stats, career highlights
- **IN MEMORIAM**: Deprecated code, closed issues, bugs squashed
- **FINALE**: Standing ovation, confetti, thank-you montage
### Project Awards Are the Star
The Best Project segment should be the emotional centerpiece. For each of the user's top 3 projects:
1. **Build the suspense** - "And the award for Best Project goes to..."
2. **Dramatic reveal** - Envelope opens, name appears with fanfare
3. **Acceptance speech** - Full spotlight with description and body text
4. **Show the stats** - Commits, impact, what made it special
5. **Applause moment** - Confetti, sparkles, celebration
Think of each project like a winner taking the stage - they deserve their moment in the spotlight.
## Closing Scene
End with that classic awards show finale:
- "What a year it's been. Congratulations to all our winners."
- "Thank you for being part of this journey."
- "Until next year... keep making magic."
---
## Recommended Helpers for Awards Show Vibe
Access helpers by destructuring from `globalThis` at the top of your file:
```javascript
const {
// Elegant backgrounds
gradient, sparkles, stars,
// Celebration particles
confetti, burst, glitter,
// Dramatic transitions
circleReveal, fade, dissolve, blindsH,
curtainReveal, spotlightReveal,
// Text effects
drawTypewriterCentered, slideIn, drawFadeInText,
drawZoomText, drawGlitchText,
// Awards-specific effects
envelopeReveal, awardBadge, acceptanceSpeech,
nomineeCard, trophyDisplay, applauseMeter,
redCarpetBorder, spotlightText, winnerAnnouncement,
categoryTitle, standingOvation, awardsStatue,
} = globalThis;
```
### Glamorous Background Combinations
```javascript
// Subtle sparkle (gala atmosphere)
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
sparkles(fb, frame, { density: 0.003, chars: ['·', '*', '✦'] });
// Starry night (outdoor ceremony feel)
stars(fb, frame, { density: 0.008, twinkle: true });
```
### Dramatic Transitions
```javascript
// Curtain reveal (opening/segment changes)
curtainReveal(fb, progress);
// Spotlight reveal (winner announcements)
spotlightReveal(fb, progress, { x: 40, y: 12 });
// Circle reveal for dramatic moments
circleReveal(fb, progress);
// Fade for emotional moments
fade(fb, progress);
```
### Celebration Effects
```javascript
// Victory confetti burst
confetti(fb, frame, { count: 30, chars: ['*', '◆', '●', '✦'] });
// Golden glitter shower
glitter(fb, frame, { density: 0.008 });
// Burst for announcements
burst(fb, frame, { x: 40, y: 12, count: 15 });
```
### Text Animation Examples
```javascript
// Dramatic zoom for winner names
drawZoomText(fb, y, 'claude-code', progress);
// Typewriter for "And the award goes to..."
drawTypewriterCentered(fb, y, 'AND THE AWARD GOES TO...', progress, frame);
// Slide in for category names
slideIn(fb, y, 'BEST PROJECT', progress, { from: 'left' });
// Spotlight text (glowing effect)
spotlightText(fb, y, 'WINNER', frame, { glow: true });
```
---
## Awards-Specific Effects Reference
### Envelope Reveal
```javascript
// Dramatic envelope opening animation
envelopeReveal(fb, 'claude-code', progress, frame, {
y: 10,
suspenseText: 'AND THE AWARD GOES TO...',
});
// Phases: envelope appears → opens → winner name revealed with fanfare
```
### Award Badge
```javascript
// Display an award badge/medal
awardBadge(fb, x, y, {
category: 'BEST PROJECT',
year: '2024',
style: 'gold', // 'gold', 'silver', 'bronze'
}, progress);
// Output:
// ╭─────────────╮
// │ ★ 2024 ★ │
// │ BEST PROJECT│
// ╰─────────────╯
```
### Trophy Display
```javascript
// ASCII trophy with label
trophyDisplay(fb, x, y, {
label: '#1 PROJECT',
style: 'grand', // 'grand', 'simple', 'star'
}, progress, frame);
// Output:
// ___
// | |
// /| |\
// / |___| \
// | / \ |
// \/_____\/
// #1 PROJECT
```
### Acceptance Speech (Project Spotlight) ⭐
**This is the star helper for the awards show vibe.** Use it to give each project its own acceptance speech moment.
```javascript
// Full acceptance speech display for a project
acceptanceSpeech(fb, {
name: 'claude-code',
commits: 275,
rank: 1,
description: 'CLI tool for developers',
body: 'I want to thank everyone who contributed to this project. The late nights, the debugging sessions, the code reviews - it all led to this moment.',
}, progress, frame, {
y: 4,
width: 55,
showTrophy: true,
});
// Output:
// ___
// | |
// /| |\
// / |___| \
// | / \ |
// \/_____\/
// ╔═══════════════════════════════════════════════════╗
// ║ ★ BEST PROJECT ★ ║
// ║ claude-code ║
// ╟───────────────────────────────────────────────────╢
// ║ 275 COMMITS ║
// ║ CLI tool for developers ║
// ║ ················· ║
// ║ I want to thank everyone who contributed to ║
// ║ this project. The late nights, the debugging ║
// ║ sessions, the code reviews - it all led to ║
// ║ this moment. ║
// ╚═══════════════════════════════════════════════════╝
```
### Nominee Card
```javascript
// Display a nominee before winner announcement
nomineeCard(fb, x, y, {
name: 'sdk-demos',
stat: 27,
statLabel: 'commits',
}, progress, {
style: 'elegant',
width: 30,
});
```
### Category Title
```javascript
// Animated category header
categoryTitle(fb, y, 'BEST PROJECT', progress, frame, {
style: 'grand', // 'grand', 'simple', 'minimal'
});
// Output:
// ════════════════════════════════════════
// ★ BEST PROJECT ★
// ════════════════════════════════════════
```
### Winner Announcement
```javascript
// Full winner reveal sequence
winnerAnnouncement(fb, 'claude-code', progress, frame, {
category: 'BEST PROJECT',
stat: 275,
statLabel: 'commits',
});
// Handles the full reveal: category → suspense → winner → celebration
```
### Applause Meter
```javascript
// Visual applause indicator (like an audience reaction)
applauseMeter(fb, y, progress, frame, {
intensity: 0.8, // 0-1 how enthusiastic
});
// Output: 👏👏👏👏👏👏👏░░░
```
### Standing Ovation
```javascript
// Particle effect for standing ovation moment
standingOvation(fb, frame, {
intensity: 1.0,
chars: ['👏', '✦', '*', '·'],
});
```
### Red Carpet Border
```javascript
// Decorative border with awards show feel
redCarpetBorder(fb, progress, {
style: 'velvet', // 'velvet', 'gold', 'stars'
});
```
### Awards Statue
```javascript
// Large decorative trophy/statue ASCII art
awardsStatue(fb, x, y, progress, {
style: 'oscar', // 'oscar', 'globe', 'star'
});
```
---
### Sample Phrases
- "Welcome to the ceremony..."
- "And the nominees are..."
- "The envelope, please..."
- "And the award goes to..."
- "Let's give them a round of applause!"
- "What an incredible achievement."
- "A truly remarkable year."
- "Please welcome to the stage..."
- "Thank you to everyone who made this possible."
---
### Example Scene Structure
```javascript
case 'BEST_PROJECT': {
// Glamorous background
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
sparkles(fb, frame, { density: 0.003, chars: ['·', '*'] });
// Category title
if (p < 0.15) {
categoryTitle(fb, 5, 'BEST PROJECT', p / 0.15, frame, { style: 'grand' });
}
// Suspense build
if (p >= 0.15 && p < 0.3) {
const suspenseP = (p - 0.15) / 0.15;
drawTypewriterCentered(fb, 10, 'AND THE AWARD GOES TO...', suspenseP, frame);
}
// Winner reveal with envelope
if (p >= 0.3 && p < 0.5) {
const revealP = (p - 0.3) / 0.2;
envelopeReveal(fb, 'claude-code', revealP, frame, { y: 8 });
}
// Acceptance speech
if (p >= 0.5) {
const speechP = (p - 0.5) / 0.5;
acceptanceSpeech(fb, {
name: 'claude-code',
commits: 275,
rank: 1,
description: 'CLI tool for developers',
body: 'Major refactors to agent architecture shipped this year. The CLI became faster, smarter, and more powerful.',
}, speechP, frame, {
y: 4,
width: 55,
showTrophy: true,
});
// Celebration
if (speechP > 0.3) {
confetti(fb, frame, { count: 20 });
}
}
break;
}
```
---
### Example: Full Awards Sequence (The Star of the Show)
This is how you make projects shine. Each of the top 3 projects gets their own awards ceremony moment:
```javascript
case 'PROJECT_AWARDS': {
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
const projects = [
{
name: 'claude-code',
commits: 275,
rank: 1,
description: 'The heart of Claude Code',
body: 'Major refactors to agent architecture, new subagent system, and animation framework shipped. A year of transformation.',
},
{
name: 'sdk-demos',
commits: 27,
rank: 2,
description: 'Example applications & demos',
body: 'Email agent, deep research demos, and customer showcase apps. Helping teams see the art of the possible.',
},
{
name: 'docs',
commits: 15,
rank: 3,
description: 'Documentation & guides',
body: 'User guides, API reference, and tutorials. Good docs make great products.',
},
];
// Each project gets dedicated screen time with full ceremony
const numProjects = projects.length;
const projectIdx = Math.min(numProjects - 1, Math.floor(p * numProjects));
const projectLocalP = (p * numProjects) % 1;
const project = projects[projectIdx];
// Phase 1: Category reveal (0-0.2)
if (projectLocalP < 0.2) {
categoryTitle(fb, 5, `#${project.rank} PROJECT`, projectLocalP / 0.2, frame);
}
// Phase 2: Envelope reveal (0.2-0.4)
if (projectLocalP >= 0.2 && projectLocalP < 0.4) {
const envP = (projectLocalP - 0.2) / 0.2;
envelopeReveal(fb, project.name, envP, frame, { y: 8 });
}
// Phase 3: Acceptance speech (0.4-1.0)
if (projectLocalP >= 0.4) {
const speechP = (projectLocalP - 0.4) / 0.6;
acceptanceSpeech(fb, project, speechP, frame, {
y: 4,
width: 55,
showTrophy: true,
});
// Celebration particles
if (speechP > 0.2) {
confetti(fb, frame, { count: 15, chars: ['*', '✦', '·'] });
}
}
// Progress indicator
fb.drawText(fb.width - 5, 2, `${projectIdx + 1}/${numProjects}`);
break;
}
```

View File

@@ -0,0 +1,92 @@
# Cozy Vibe Instructions
Generate a warm, gentle, and comforting thinkback experience. Think blankets, hot cocoa, gathering around a fire, and quiet satisfaction.
Imagine you are a parent giving your child a bedtime story.
## Tone Guidelines
- **Warm and gentle**: Use soft, nurturing language
- **Appreciative**: Focus on the journey, not just achievements
- **Unhurried**: Let moments breathe, no rushing through stats
- **Nostalgic**: Frame the year as memories worth cherishing
## Pacing
- Slower transitions between scenes
- Longer pauses for reflection
- Let ASCII art fade in gently rather than appearing abruptly
## Closing Scene
End with gratitude and warmth, not a call to action:
- "Until next year... take care of yourself"
- "The code will be here when you're ready"
- "Rest well. You've earned it."
---
## Recommended Helpers for Cozy Vibe
Import these helpers for a warm, gentle atmosphere:
```javascript
import {
// Cozy backgrounds
stars, fireflies, dust, snow,
// Gentle particles
floatingParticles, embers, sparkles,
// Soft transitions
dissolve, circleReveal, circleClose,
// Text effects
drawTypewriterCentered, drawFadeInText, slideIn,
} from './helpers/index.js';
```
### Cozy Background Combinations
```javascript
// Gentle starfield (slow twinkle)
stars(fb, frame, { density: 0.006, twinkle: true });
// Warm fireflies (like a summer evening)
fireflies(fb, frame, { count: 6, chars: ['·', '*', '°'] });
// Dust motes in afternoon light
dust(fb, frame, { density: 0.002 });
// Light snow for winter scenes
snow(fb, frame, { density: 0.008, chars: ['·', '.'] });
```
### Gentle Particle Effects
```javascript
// Floating diamonds (signature cozy particle)
floatingParticles(fb, frame, { count: 12, char: '◇', speed: 0.5 });
// Rising embers (warm hearth feeling)
embers(fb, frame, { count: 8, chars: ['.', '·', '*'], speed: 0.7 });
// Subtle sparkles (magical but not overwhelming)
sparkles(fb, frame, { density: 0.003, chars: ['·', '*', '°'] });
```
### Soft Transitions
Avoid harsh wipes. Use gentle reveals:
```javascript
// Dissolve (gentle fade between scenes)
dissolve(fb, progress, seed);
// Circle reveal from center (soft iris)
circleReveal(fb, progress);
// Fade using density characters
fade(fb, progress, false);
```

View File

@@ -0,0 +1,443 @@
# Morning News Vibe Instructions
Generate a cheerful, upbeat news broadcast experience. Think morning show energy, breaking news graphics, weather-style stat presentations, and that signature "and finally..." feel-good closer.
Imagine you are a morning news anchor delivering the year's top developer stories with a smile.
## Tone Guidelines
- **Upbeat and professional**: Friendly but polished, like your favorite morning hosts
- **Informative**: Present stats like they're newsworthy headlines
- **Light humor**: Occasional puns and wordplay, never forced
- **Celebratory**: Treat achievements like breaking good news
## Pacing
- Snappy transitions between "segments"
- Dramatic pauses before big reveals ("And the number one story...")
- Quick "ticker tape" style for smaller stats
- Slow down for the heartfelt "human interest" closer
## Segment Ideas
Structure the thinkback like a news broadcast:
- **BREAKING**: The biggest stat or achievement
- **TOP STORIES**: Key metrics from the year
- **PROJECT SPOTLIGHT**: **THE STAR OF THE SHOW** - Feature the top 3 accomplishements of the user as full news articles, one at a time. Each accomplishment gets its own dedicated screen with headline, description, body text, and stats. Use `accomplishmentSpotlight` to give each project the attention it deserves.
- **WEATHER**: Coding "forecast" (busy periods, productivity patterns)
- **SPORTS**: Competitive stats (lines of code, PRs merged)
- **AND FINALLY...**: Warm, feel-good closer
### Project Articles Are the Star
The project spotlight segment should be the centerpiece of the broadcast. For each of the user's top 3 projects:
1. **Give it a full screen** - Don't cram multiple projects together
2. **Write a compelling headline** - Make it sound newsworthy
3. **Include body text** - 2-3 sentences about what was accomplished
4. **Show the stats** - Commits, contributions, impact
5. **Use transitions** - Smooth handoffs between projects
Think of each project like a feature story on the evening news - it deserves time and attention.
## Closing Scene
End with that classic news sign-off warmth:
- "That's all for 2024. See you bright and early next year."
- "From all of us here at the terminal... goodnight."
- "Stay curious, stay coding, and we'll see you tomorrow."
---
## Recommended Helpers for Morning News Vibe
Access helpers by destructuring from `globalThis` at the top of your file:
```javascript
const {
// Clean backgrounds
gradient,
// Celebration particles (for big reveals)
confetti, sparkles, burst,
// Professional transitions
wipeRight, wipeDown, blindsH, blindsV,
dissolve, fade, splitWipe, pushTransition,
// Text effects
drawTypewriterCentered, slideIn, headlineCrawl,
// News-specific effects
lowerThird, tickerTape, breakingBanner, liveIndicator,
segmentTitle, statCounter, forecastBar, countdownReveal,
// Article display helpers
newsArticle, newsGrid, headlineCarousel, accomplishmentSpotlight, newsFeed,
} = globalThis;
```
### Broadcast Background Combinations
```javascript
// Subtle gradient (news desk feel)
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
```
### News-Style Transitions
```javascript
// Wipe right (classic news transition)
wipeRight(fb, progress);
// Venetian blinds (segment change)
blindsH(fb, progress, 4);
// Dissolve for softer moments
dissolve(fb, progress, seed);
```
### Breaking News Effects
```javascript
// Confetti for big achievements
confetti(fb, frame, { count: 20, chars: ['*', '◆', '●'] });
// Burst for "BREAKING" moments
burst(fb, frame, { x: 40, y: 12, count: 10 });
// Sparkles for feel-good segments
sparkles(fb, frame, { density: 0.004, chars: ['·', '*'] });
```
### Text Animation Examples
```javascript
// Headline with blinking cursor
headlineCrawl(fb, y, 'BREAKING: 50,000 LINES WRITTEN', progress, frame, {
centered: true,
});
// Slide in for segment titles
slideIn(fb, y, '>>> TOP STORIES', progress, { from: 'left' });
// Segment title with decorative brackets
segmentTitle(fb, y, 'TOP STORIES', progress, { style: 'arrow' });
// Outputs: ▸▸▸ TOP STORIES
// Animated stat counter
statCounter(fb, x, y, 1247, progress, {
prefix: 'COMMITS: ',
commas: true,
});
```
---
## News-Specific Effects Reference
### Lower Third (Info Bar)
```javascript
// News-style stat display bar
lowerThird(fb, 20, 'COMMITS THIS YEAR', '1,247', progress, {
style: 'heavy', // 'single', 'double', 'heavy'
accentChar: '▌',
});
// Output:
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃▌ COMMITS THIS YEAR 1,247 ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
```
### Ticker Tape
```javascript
// Scrolling news ticker
tickerTape(fb, 23, [
'47 PRs merged',
'12 repos touched',
'892 files changed',
'156 bugs squashed',
], frame, { separator: ' ▸ ', speed: 0.5 });
// Output: 47 PRs merged ▸ 12 repos touched ▸ 892 files changed ▸ ...
```
### Breaking News Banner
```javascript
// Flashing breaking news alert
breakingBanner(fb, 10, 'NEW PERSONAL BEST', frame, { flash: true });
// Output:
// ╔═══════════════════════════════════════╗
// ║ ⚡ NEW PERSONAL BEST ⚡ ║
// ╚═══════════════════════════════════════╝
```
### Live Indicator
```javascript
// Blinking LIVE badge (top corner)
liveIndicator(fb, 2, 1, frame, { speed: 0.3 });
// Output: ● LIVE (dot blinks)
```
### Forecast Bar (Weather-Style)
```javascript
// Horizontal bar chart for stats
forecastBar(fb, 5, 10, 'JANUARY', 0.3, 40, progress);
forecastBar(fb, 5, 11, 'FEBRUARY', 0.5, 40, progress);
forecastBar(fb, 5, 12, 'MARCH', 0.8, 40, progress);
// Output:
// JANUARY ███████░░░░░░░░░░░░░
// FEBRUARY █████████████░░░░░░░
// MARCH ████████████████████
```
### Split Wipe Transition
```javascript
// News-style wipe from center outward
splitWipe(fb, progress);
```
### Push Transition
```javascript
// Content slides off as new content slides in
pushTransition(fb, progress, 'left'); // 'left', 'right', 'up', 'down'
```
### Countdown Reveal
```javascript
// Dramatic "3... 2... 1... GO!" countdown
countdownReveal(fb, progress, {
numbers: ['3', '2', '1', 'GO!'],
});
```
---
### Sample Phrases
- "Good morning! Let's look at the numbers..."
- "Breaking overnight: a new milestone reached"
- "In developer news today..."
- "Our top story this hour..."
- "And now for your coding forecast..."
- "In sports: a record-breaking performance"
- "And finally, a story that will warm your heart..."
- "That's the news. Thanks for watching."
---
### Example Scene Structure
```javascript
case 'BREAKING': {
// Background
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
// Live indicator in corner
liveIndicator(fb, 2, 1, frame);
// Breaking banner
if (p < 0.3) {
breakingBanner(fb, 8, 'NEW PERSONAL BEST', frame, { flash: true });
}
// Lower third with stat
if (p > 0.4) {
lowerThird(fb, 18, 'TOTAL COMMITS', '1,247', (p - 0.4) / 0.6);
}
// Ticker at bottom
tickerTape(fb, 23, ['47 PRs', '12 repos', '892 files'], frame);
// Transition out
if (p > 0.9) {
pushTransition(fb, (p - 0.9) / 0.1, 'left');
}
break;
}
```
---
## Article Display Helpers Reference
### News Article (Full Article Card)
```javascript
// Display a complete news article card
newsArticle(fb, 5, 3, {
category: 'TECH',
headline: 'NEW FEATURE SHIPS',
subhead: 'Users love the update',
body: 'The new feature has been deployed to production and is already seeing great adoption.',
stat: 1247,
statLabel: 'users affected',
}, progress, {
width: 45,
style: 'boxed', // 'boxed', 'minimal', 'breaking'
});
// Output:
// ┌───────────────────────────────────────────┐
// TECH
// NEW FEATURE SHIPS
// Users love the update
// ···········
// The new feature has been deployed to
// production and is already seeing...
// 1,247
// users affected
// └───────────────────────────────────────────┘
```
### News Grid (Multiple Articles)
```javascript
// Display multiple articles in a grid layout
newsGrid(fb, [
{ category: 'COMMITS', headline: 'Record Month', stat: 275 },
{ category: 'FEATURES', headline: 'SDK Released', stat: 15 },
{ category: 'DOCS', headline: 'Guides Updated', stat: 42 },
{ category: 'FIXES', headline: 'Bugs Squashed', stat: 89 },
], progress, {
columns: 2,
startY: 4,
startX: 2,
spacing: 2,
articleWidth: 35,
staggerDelay: 0.15, // Stagger animation for each article
});
```
### Project Spotlight (Featured Project) ⭐
**This is the star helper for the morning news vibe.** Use it to give each project its own dedicated feature article.
```javascript
// Highlight a single project with full details
accomplishmentSpotlight(fb, {
name: 'claude-code',
commits: 275,
rank: 1,
description: 'CLI tool for developers',
body: 'Major refactors to agent architecture shipped this year. The CLI became faster, smarter, and more powerful with new subagent capabilities.',
}, progress, frame, {
y: 5,
width: 50,
centered: true,
showRank: true,
});
// Output:
// ╔════════════════════════════════════════════════╗
// #1 PROJECT
// ║ claude-code ║
// ╟────────────────────────────────────────────────╢
// ║ 275 COMMITS ║
// ║ CLI tool for developers ║
// ║ ··················· ║
// ║ Major refactors to agent architecture ║
// ║ shipped this year. The CLI became faster, ║
// ║ smarter, and more powerful with new ║
// ║ subagent capabilities. ║
// ╚════════════════════════════════════════════════╝
```
### Headline Carousel (Rotating Headlines)
```javascript
// Cycle through headlines with animations
headlineCarousel(fb, [
'RECORD COMMITS THIS WEEK',
'NEW PERSONAL BEST ACHIEVED',
'DOCUMENTATION SHIPPED',
], progress, frame, {
y: 10,
style: 'crawl', // 'crawl', 'slide', 'fade'
});
```
### News Feed (Scrolling Headlines)
```javascript
// Vertical scrolling news feed
newsFeed(fb, [
{ category: 'BREAKING', headline: 'New milestone reached' },
{ category: 'TECH', headline: 'API improvements deployed' },
{ category: 'SPORTS', headline: 'Streak continues: 16 days' },
], frame, {
x: 2,
y: 4,
width: 40,
height: 15,
speed: 0.1,
});
```
---
### Example: Project Carousel Scene (The Star of the Show)
This is how you make projects shine. Each of the top 3 projects gets its own full-screen feature article:
```javascript
case 'PROJECT_SPOTLIGHT': {
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
liveIndicator(fb, 2, 1, frame);
segmentTitle(fb, 3, 'PROJECT SPOTLIGHT', Math.min(1, p / 0.2), { style: 'arrow' });
// Top 3 projects - each gets its own feature article
const projects = [
{
name: 'claude-code',
commits: 275,
rank: 1,
description: 'The heart of Claude Code',
body: 'Major refactors to agent architecture, new subagent system, and animation framework shipped. The CLI became faster, smarter, and more powerful.',
},
{
name: 'sdk-demos',
commits: 27,
rank: 2,
description: 'Example applications & demos',
body: 'Email agent, deep research demos, and customer showcase apps built to demonstrate SDK capabilities. These demos help teams see the art of the possible.',
},
{
name: 'docs',
commits: 15,
rank: 3,
description: 'Documentation & guides',
body: 'User guides, API reference, and tutorials to help developers get started quickly. Good docs make great products.',
},
];
// Carousel through projects - each gets dedicated screen time
const numProjects = projects.length;
if (p > 0.15) {
const carouselP = (p - 0.15) / 0.8;
const projectIdx = Math.min(numProjects - 1, Math.floor(carouselP * numProjects));
const projectLocalP = (carouselP * numProjects) % 1;
// Use accomplishmentSpotlight for the full article experience
accomplishmentSpotlight(fb, projects[projectIdx], Math.min(1, projectLocalP * 1.5), frame, {
y: 5,
width: 55,
centered: true,
showRank: true,
});
// Progress indicator
fb.drawText(fb.width - 5, 3, `${projectIdx + 1}/${numProjects}`);
}
tickerTape(fb, 23, projects.map(p => `#${p.rank} ${p.name}: ${p.commits} commits`), frame);
break;
}
```

View File

@@ -0,0 +1,5 @@
# Other Vibe Instructions
The user is not able to select other and give custom instructions.
Ask them again using the AskUserQuestion Tool what vibe they would like from: cozy, awards show, morning news and rpg quest.

View File

@@ -0,0 +1,603 @@
# RPG Quest Vibe Instructions
Generate an epic RPG adventure experience. Think quest logs, hero's journey, level-ups, and legendary achievements. Frame the year as a completed adventure with dungeons conquered and bosses defeated.
Imagine you are the narrator of an 8-bit RPG recounting a hero's legendary year of quests.
## Tone Guidelines
- **Epic but warm**: Heroic language without being overwrought
- **Nostalgic RPG flavor**: References to classic games (Final Fantasy, Dragon Quest, Zelda)
- **Achievement-focused**: Every project is a quest completed, every stat is XP earned
- **Encouraging**: The hero has grown stronger through their journey
## Pacing
- Classic RPG text box reveals (character by character with sound effect feeling)
- Dramatic pauses before big stat reveals ("You gained... 1,247 XP!")
- Quest completion fanfares for project spotlights
- Slow, reflective ending like a credits roll
## Segment Ideas
Structure the thinkback like an RPG adventure:
- **TITLE SCREEN**: "Press Start" intro, game logo, year
- **CHARACTER SELECT**: User's "class" based on their work patterns
- **ADVENTURE BEGINS**: Set the scene, the hero enters the codebase
- **QUEST LOG**: **THE STAR OF THE SHOW** - Top 3 projects as completed quests, each with its own full quest card showing objectives, rewards, and story
- **BOSS BATTLES**: Major challenges overcome (big refactors, critical bugs, launches)
- **LEVEL UP**: Stats gained, skills acquired, growth over the year
- **CREDITS ROLL**: Warm closing, "Your adventure continues..."
### Quest Cards Are the Star
The quest log segment should be the emotional centerpiece. For each of the user's top 3 projects:
1. **Quest banner** - "QUEST COMPLETE" fanfare with quest name
2. **Objectives list** - What was accomplished (commits, features, fixes)
3. **Quest story** - 2-3 sentences about the journey
4. **Rewards earned** - XP, gold (commits), items (skills learned)
5. **Completion flourish** - Sparkles, level-up sound effect feeling
Think of each project like completing a major side quest - it deserves a full quest completion screen.
## Character Classes
Assign the user a class based on their work patterns:
- **Bug Slayer**: Lots of fixes, issue closures
- **Feature Crafter**: New functionality, enhancements
- **Docs Wizard**: Documentation, guides, READMEs
- **Refactor Knight**: Code improvements, cleanup
- **Full Stack Paladin**: Balanced across all areas
- **Speed Demon**: High commit velocity
- **Deep Delver**: Long-running complex projects
## Closing Scene
End with classic RPG warmth:
- "Your adventure continues in 2025..."
- "SAVE COMPLETE. See you next quest."
- "The hero rests... but new adventures await."
- "TO BE CONTINUED..."
---
## Recommended Helpers for RPG Quest Vibe
Access helpers by destructuring from `globalThis` at the top of your file:
```javascript
const {
// Backgrounds
starfield, gradient,
// Celebration particles
confetti, sparkles, burst, glitter,
// Transitions
pixelate, blindsH, blindsV, wipeRight, wipeDown,
fade, dissolve,
// Text effects
drawTypewriterCentered, drawZoomText, slideIn,
drawWaveText, drawGlitchText,
// RPG-specific effects
questCard, questBanner, xpBar, levelUp,
textBox, characterSprite, statsPanel,
inventorySlot, bossHealth, victoryFanfare,
titleScreen, classSelect, creditsRoll,
} = globalThis;
```
### Background Combinations
```javascript
// Starfield for title screen / space dungeons
starfield(fb, frame, { speed: 1, numStars: 30 });
// Subtle gradient for quest screens
gradient(fb, { direction: 'vertical', chars: [' ', '·', '░'] });
```
### Classic RPG Transitions
```javascript
// Pixelate (battle transition feel)
pixelate(fb, progress, 8);
// Blinds (menu/screen change)
blindsH(fb, progress, 4);
// Fade for emotional moments
fade(fb, progress);
```
### Celebration Effects
```javascript
// Quest complete sparkles
sparkles(fb, frame, { density: 0.006, chars: ['*', '+', '·'] });
// Level up burst
burst(fb, frame, { x: 40, y: 12, count: 12 });
// Victory confetti
confetti(fb, frame, { count: 20, chars: ['*', '◆', '●', '▲'] });
```
### Text Animation Examples
```javascript
// RPG text box style (character by character)
textBox(fb, y, 'A new quest awaits...', progress, frame, {
width: 50,
style: 'rpg',
});
// Dramatic zoom for numbers
drawZoomText(fb, y, '+1,247 XP', progress);
// Wave text for celebration
drawWaveText(fb, y, 'LEVEL UP!', frame, { amplitude: 1, speed: 2 });
// Slide in for menu items
slideIn(fb, y, '> QUEST LOG', progress, { from: 'left' });
```
---
## RPG-Specific Effects Reference
### Title Screen
```javascript
// Classic RPG title screen
titleScreen(fb, {
title: 'YEAR IN CODE',
subtitle: '2024',
prompt: 'PRESS START',
}, progress, frame);
// Output:
//
// ╔═══════════════════════════════╗
// ║ ║
// ║ YEAR IN CODE ║
// ║ 2024 ║
// ║ ║
// ║ PRESS START ║ (blinking)
// ║ ║
// ╚═══════════════════════════════╝
```
### Text Box (RPG Dialog Style)
```javascript
// Classic RPG text box with character-by-character reveal
textBox(fb, 16, 'The hero embarked on a year of epic quests...', progress, frame, {
width: 60,
style: 'rpg', // 'rpg', 'modern', 'minimal'
speaker: 'NARRATOR',
});
// Output:
// ┌─ NARRATOR ─────────────────────────────────────────────┐
// │ The hero embarked on a year of epic quests... │
// │ ▼ │
// └────────────────────────────────────────────────────────┘
```
### Class Select
```javascript
// Character class display
classSelect(fb, {
className: 'FEATURE CRAFTER',
description: 'A builder of new worlds',
stats: { STR: 8, DEX: 6, INT: 9, WIS: 7 },
traits: ['Creative', 'Persistent', 'Ambitious'],
}, progress, frame, {
y: 5,
showSprite: true,
});
// Output:
// ╭───────╮
// │ ◉◡◉ │
// │ /|\ │
// │ / \ │
// ╰───────╯
// FEATURE CRAFTER
// "A builder of new worlds"
//
// STR ████████░░ 8
// DEX ██████░░░░ 6
// INT █████████░ 9
// WIS ███████░░░ 7
//
// [Creative] [Persistent] [Ambitious]
```
### Quest Card (Project Spotlight)
**This is the star helper for the RPG quest vibe.** Use it to give each project a full quest completion screen.
```javascript
// Full quest completion card
questCard(fb, {
name: 'claude-code',
commits: 275,
rank: 1,
description: 'The legendary CLI tool',
body: 'The hero ventured deep into the codebase, refactoring ancient architectures and forging new subagent systems. Many bugs were slain along the way.',
}, progress, frame, {
y: 3,
width: 55,
showRewards: true,
});
// Output:
// ╔═══════════════════════════════════════════════════════╗
// ║ ★ QUEST COMPLETE ★ ║
// ╟───────────────────────────────────────────────────────╢
// ║ ║
// ║ claude-code ║
// ║ "The legendary CLI tool" ║
// ║ ───────────────────────────────────────────────── ║
// ║ The hero ventured deep into the codebase, ║
// ║ refactoring ancient architectures and forging ║
// ║ new subagent systems. Many bugs were slain ║
// ║ along the way. ║
// ║ ║
// ║ ┌─ REWARDS ─────────────────────────────────────┐ ║
// ║ │ +275 XP +3 Skills +1 Legendary Item │ ║
// ║ └───────────────────────────────────────────────┘ ║
// ╚═══════════════════════════════════════════════════════╝
```
### Quest Banner
```javascript
// Animated quest complete banner
questBanner(fb, 'QUEST COMPLETE', progress, frame, {
y: 2,
style: 'fanfare', // 'fanfare', 'simple', 'legendary'
});
// Output (with animation):
// ·:·:·:·:· QUEST COMPLETE ·:·:·:·:·
```
### XP Bar
```javascript
// Animated XP/progress bar
xpBar(fb, x, y, {
current: 1247,
max: 2000,
label: 'LEVEL 7',
}, progress, {
width: 40,
showNumbers: true,
});
// Output:
// LEVEL 7 ████████████████████░░░░░░░░░░ 1,247 / 2,000 XP
```
### Level Up
```javascript
// Level up celebration effect
levelUp(fb, {
level: 7,
stats: [
{ name: 'COMMITS', gained: '+275' },
{ name: 'PROJECTS', gained: '+3' },
{ name: 'SKILLS', gained: '+5' },
],
}, progress, frame, {
y: 8,
});
// Output:
// ╔═══════════════════╗
// ║ LEVEL UP! ║
// ║ LV. 7 ║
// ╠═══════════════════╣
// ║ COMMITS +275 ║
// ║ PROJECTS +3 ║
// ║ SKILLS +5 ║
// ╚═══════════════════╝
```
### Stats Panel
```javascript
// RPG-style stats display
statsPanel(fb, x, y, {
'COMMITS': 1247,
'QUESTS': 12,
'BUGS SLAIN': 89,
'FEATURES': 34,
}, progress, {
style: 'bordered',
width: 30,
});
// Output:
// ┌─ HERO STATS ─────────────┐
// │ COMMITS 1,247 │
// │ QUESTS 12 │
// │ BUGS SLAIN 89 │
// │ FEATURES 34 │
// └──────────────────────────┘
```
### Character Sprite
```javascript
// Simple ASCII character sprite
characterSprite(fb, x, y, {
class: 'FEATURE_CRAFTER',
animate: true,
}, frame);
// Output (animated):
// ◉◡◉
// /|\
// / \
```
### Boss Health Bar
```javascript
// Boss battle health bar (for challenges overcome)
bossHealth(fb, y, {
name: 'LEGACY CODEBASE',
health: 0, // Defeated!
maxHealth: 100,
}, progress, frame);
// Output:
// LEGACY CODEBASE
// [░░░░░░░░░░░░░░░░░░░░] DEFEATED!
```
### Victory Fanfare
```javascript
// Victory celebration effect
victoryFanfare(fb, frame, {
intensity: 1.0,
style: 'classic', // 'classic', 'epic', 'subtle'
});
// Triggers sparkles, bursts, and celebration particles
```
### Credits Roll
```javascript
// Scrolling credits with RPG feel
creditsRoll(fb, [
{ type: 'header', text: 'QUEST COMPLETE' },
{ type: 'stat', label: 'Total XP', value: '12,470' },
{ type: 'stat', label: 'Quests Completed', value: '47' },
{ type: 'stat', label: 'Bugs Slain', value: '156' },
{ type: 'spacer' },
{ type: 'text', text: 'Your adventure continues...' },
{ type: 'text', text: '2025' },
], progress, frame, {
speed: 0.5,
});
```
### Inventory Slot
```javascript
// Show acquired "items" (skills, tools, achievements)
inventorySlot(fb, x, y, {
icon: '⚔',
name: 'TypeScript',
rarity: 'epic', // 'common', 'rare', 'epic', 'legendary'
}, progress);
// Output:
// ┌───┐
// │ ⚔ │ TypeScript
// └───┘ ★★★☆
```
---
### Sample Phrases
- "A new quest awaits..."
- "The hero ventured forth into the codebase..."
- "Quest complete! You gained 275 XP."
- "A legendary bug has been slain!"
- "Level up! Your skills have grown."
- "The dungeon has been cleared."
- "Your party grows stronger."
- "A new skill has been learned!"
- "Victory! The refactor is complete."
- "Save complete. Your progress has been recorded."
- "The adventure continues..."
---
### Example Scene Structure
```javascript
case 'TITLE_SCREEN': {
starfield(fb, frame, { speed: 1, numStars: 25 });
titleScreen(fb, {
title: 'YEAR IN CODE',
subtitle: '2024',
prompt: 'PRESS START',
}, p, frame);
// Transition out
if (p > 0.85) {
pixelate(fb, (p - 0.85) / 0.15, 8);
}
break;
}
case 'CLASS_REVEAL': {
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
// Build up
if (p < 0.3) {
textBox(fb, 18, 'Your deeds have defined you...', p / 0.3, frame, {
width: 50,
style: 'rpg',
});
}
// Class reveal
if (p >= 0.3) {
const classP = (p - 0.3) / 0.7;
classSelect(fb, {
className: 'FEATURE CRAFTER',
description: 'A builder of new worlds',
stats: { STR: 8, DEX: 6, INT: 9, WIS: 7 },
traits: ['Creative', 'Persistent', 'Ambitious'],
}, classP, frame, {
y: 4,
showSprite: true,
});
if (classP > 0.5) {
sparkles(fb, frame, { density: 0.004 });
}
}
break;
}
```
---
### Example: Quest Log Sequence (The Star of the Show)
This is how you make projects shine. Each of the top 3 projects gets a full quest completion screen:
```javascript
case 'QUEST_LOG': {
gradient(fb, { direction: 'vertical', chars: [' ', '░'] });
const projects = [
{
name: 'claude-code',
commits: 275,
rank: 1,
description: 'The legendary CLI tool',
body: 'The hero ventured deep into the codebase, refactoring ancient architectures and forging new subagent systems. Many bugs were slain along the way.',
},
{
name: 'sdk-demos',
commits: 27,
rank: 2,
description: 'Grimoire of examples',
body: 'Ancient knowledge was transcribed into demos and examples. Future adventurers will learn from these scrolls.',
},
{
name: 'docs',
commits: 15,
rank: 3,
description: 'The sacred texts',
body: 'Documentation was written to guide those who follow. The path is now clear for all.',
},
];
// Quest log header
if (p < 0.1) {
questBanner(fb, 'QUEST LOG', p / 0.1, frame, { y: 2, style: 'simple' });
}
// Cycle through quests - each gets dedicated screen time
if (p >= 0.1) {
const questP = (p - 0.1) / 0.9;
const numQuests = projects.length;
const questIdx = Math.min(numQuests - 1, Math.floor(questP * numQuests));
const questLocalP = (questP * numQuests) % 1;
const project = projects[questIdx];
// Quest complete banner
if (questLocalP < 0.2) {
questBanner(fb, 'QUEST COMPLETE', questLocalP / 0.2, frame, {
y: 2,
style: 'fanfare',
});
}
// Full quest card
if (questLocalP >= 0.2) {
const cardP = (questLocalP - 0.2) / 0.8;
questCard(fb, project, cardP, frame, {
y: 4,
width: 55,
showRewards: true,
});
// Victory sparkles
if (cardP > 0.3) {
sparkles(fb, frame, { density: 0.005, chars: ['*', '+', '·'] });
}
}
// Progress indicator
fb.drawText(fb.width - 8, 2, `${questIdx + 1}/${numQuests}`);
}
break;
}
case 'LEVEL_UP': {
gradient(fb, { direction: 'vertical', chars: [' ', '·'] });
// Dramatic build
if (p < 0.3) {
textBox(fb, 10, 'Your journey has made you stronger...', p / 0.3, frame, {
width: 45,
style: 'rpg',
});
}
// Level up reveal
if (p >= 0.3) {
const lvlP = (p - 0.3) / 0.7;
levelUp(fb, {
level: 7,
stats: [
{ name: 'COMMITS', gained: '+1,247' },
{ name: 'QUESTS', gained: '+12' },
{ name: 'BUGS SLAIN', gained: '+89' },
],
}, lvlP, frame, {
y: 6,
});
// Celebration
if (lvlP > 0.3) {
victoryFanfare(fb, frame, { intensity: lvlP });
}
}
break;
}
case 'CREDITS': {
starfield(fb, frame, { speed: 0.5, numStars: 20 });
creditsRoll(fb, [
{ type: 'header', text: 'ADVENTURE COMPLETE' },
{ type: 'spacer' },
{ type: 'stat', label: 'Total XP', value: '12,470' },
{ type: 'stat', label: 'Quests Completed', value: '12' },
{ type: 'stat', label: 'Bugs Slain', value: '89' },
{ type: 'stat', label: 'Features Forged', value: '34' },
{ type: 'spacer' },
{ type: 'spacer' },
{ type: 'text', text: 'Your adventure continues...' },
{ type: 'spacer' },
{ type: 'header', text: '2025' },
], p, frame, {
speed: 0.4,
});
break;
}
```

View File

@@ -0,0 +1,502 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>2025 Year in Review - Download</title>
<style>
body {
background: #191919;
color: #FAFAF7;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
margin: 0;
padding: 40px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
box-sizing: border-box;
}
h1 {
margin-bottom: 20px;
color: #E3703F;
}
#status {
margin-bottom: 20px;
color: #91918D;
}
#progress {
margin-bottom: 20px;
color: #FAFAF7;
}
button {
background: #E3703F;
color: #191919;
border: none;
padding: 12px 24px;
font-family: inherit;
font-size: 16px;
cursor: pointer;
border-radius: 4px;
}
button:hover {
background: #D4A27F;
}
button:disabled {
background: #40403E;
color: #666663;
cursor: not-allowed;
}
#reminder {
display: none;
margin-top: 24px;
padding: 16px 24px;
background: #2a2a28;
border: 1px solid #40403E;
border-radius: 6px;
max-width: 500px;
text-align: center;
}
#reminder h3 {
color: #E3703F;
margin: 0 0 12px 0;
font-size: 14px;
}
#reminder p {
color: #91918D;
margin: 0 0 8px 0;
font-size: 13px;
line-height: 1.5;
}
#reminder code {
background: #191919;
padding: 2px 6px;
border-radius: 3px;
color: #FAFAF7;
}
</style>
</head>
<body>
<h1>Claude Code: 2025 Year in Review</h1>
<div id="status">Ready to generate video</div>
<div id="progress"></div>
<button id="downloadBtn">Generate & Download Video</button>
<div id="reminder">
<h3>Before sharing your Thinkback</h3>
<p>Please review the video for any sensitive or confidential information (project names, commits, etc.)</p>
<p>To make changes, run <code>/thinkback</code> again and select "edit" or "regenerate".</p>
</div>
<!-- Hidden canvas for off-screen rendering -->
<canvas id="canvas" width="1152" height="1040" style="display: none;"></canvas>
<!-- MP4 muxer for WebCodecs output -->
<script src="https://cdn.jsdelivr.net/npm/mp4-muxer@5.1.3/build/mp4-muxer.min.js"></script>
<!-- Load animation helpers (sets globalThis) -->
<script src="helpers/transitions.js"></script>
<script src="helpers/backgrounds.js"></script>
<script src="helpers/text_effects.js"></script>
<script src="helpers/particles.js"></script>
<script src="helpers/borders.js"></script>
<script src="helpers/scene_system.js"></script>
<script src="helpers/awards_effects.js"></script>
<script src="helpers/news_effects.js"></script>
<script src="helpers/rpg_effects.js"></script>
<script src="year_in_review.js"></script>
<script>
// Global error handler to display errors visibly
window.onerror = function(msg, url, lineNo, columnNo, error) {
const status = document.getElementById('status');
const progress = document.getElementById('progress');
if (status) {
status.style.color = '#ff6b6b';
status.textContent = 'Error: ' + msg;
}
if (progress) {
progress.style.color = '#ff6b6b';
progress.style.whiteSpace = 'pre-wrap';
progress.style.textAlign = 'left';
progress.style.maxWidth = '800px';
const file = url ? url.split('/').pop() : 'unknown';
progress.textContent = `File: ${file}\nLine: ${lineNo}, Col: ${columnNo}\n\n${error ? error.stack : ''}\n\nPlease run /thinkback again in Claude Code to regenerate the animation.`;
}
const btn = document.getElementById('downloadBtn');
if (btn) btn.disabled = true;
return false;
};
(function() {
if (!globalThis.YearInReviewScenes) {
throw new Error('YearInReviewScenes not loaded. Check year_in_review.js for syntax errors.');
}
const { mainAnimation, TOTAL_FRAMES } = globalThis.YearInReviewScenes;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const DPI_SCALE = 2;
const CHAR_WIDTH = 7.2;
const CHAR_HEIGHT = 13;
const COLS = 80;
const ROWS = 40;
const FPS = 24;
ctx.scale(DPI_SCALE, DPI_SCALE);
class FrameBuffer {
constructor(width, height) {
this.width = width;
this.height = height;
this.buffer = [];
this.colorBuffer = [];
this.clear();
}
clear() {
this.buffer = [];
this.colorBuffer = [];
for (let y = 0; y < this.height; y++) {
this.buffer[y] = [];
this.colorBuffer[y] = [];
for (let x = 0; x < this.width; x++) {
this.buffer[y][x] = ' ';
this.colorBuffer[y][x] = null;
}
}
}
getPixel(x, y) {
x = Math.floor(x);
y = Math.floor(y);
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
return this.buffer[y][x];
}
return ' ';
}
setPixel(x, y, char, depth = 0, color = null) {
x = Math.floor(x);
y = Math.floor(y);
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.buffer[y][x] = char;
this.colorBuffer[y][x] = color;
}
}
drawText(x, y, text, depth = 0, color = null) {
x = Math.floor(x);
y = Math.floor(y);
for (let i = 0; i < text.length; i++) {
if (x + i >= 0 && x + i < this.width && y >= 0 && y < this.height) {
this.buffer[y][x + i] = text[i];
this.colorBuffer[y][x + i] = color;
}
}
}
drawCenteredText(y, text, color = null) {
const x = Math.floor((this.width - text.length) / 2);
this.drawText(x, y, text, 0, color);
}
drawLargeText(x, y, text) {
for (let i = 0; i < text.length; i++) {
const char = text[i].toUpperCase();
const pattern = FIGLET_FONT[char] || FIGLET_FONT[' '];
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 5; col++) {
if (pattern[row][col] === '@') {
this.setPixel(x + i * 6 + col, y + row, '@');
}
}
}
}
}
drawLargeTextCentered(y, text) {
const totalWidth = text.length * 6;
const x = Math.floor((this.width - totalWidth) / 2);
this.drawLargeText(x, y, text);
}
drawHorizontalLine(x, y, length, char = '-') {
for (let i = 0; i < length; i++) {
this.setPixel(x + i, y, char);
}
}
drawBox(x, y, width, height, char = '+', filled = false) {
if (filled) {
for (let dy = 0; dy < height; dy++) {
for (let dx = 0; dx < width; dx++) {
this.setPixel(x + dx, y + dy, char);
}
}
} else {
for (let dx = 0; dx < width; dx++) {
this.setPixel(x + dx, y, char);
this.setPixel(x + dx, y + height - 1, char);
}
for (let dy = 0; dy < height; dy++) {
this.setPixel(x, y + dy, char);
this.setPixel(x + width - 1, y + dy, char);
}
}
}
render() {
ctx.fillStyle = '#191919';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = '12px monospace';
// Block character rendering as rectangles for pixel-perfect display
const halfW = CHAR_WIDTH / 2;
const halfH = CHAR_HEIGHT / 2;
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const char = this.buffer[y][x];
if (char !== ' ') {
const color = this.colorBuffer[y][x] || '#FAFAF7';
const px = x * CHAR_WIDTH;
const py = y * CHAR_HEIGHT;
ctx.fillStyle = color;
// Render block characters as geometric shapes
switch (char) {
case '█': // Full block
ctx.fillRect(px, py, CHAR_WIDTH + 0.5, CHAR_HEIGHT);
break;
case '▌': // Left half
ctx.fillRect(px, py, halfW, CHAR_HEIGHT);
break;
case '▐': // Right half
ctx.fillRect(px + halfW, py, halfW + 0.5, CHAR_HEIGHT);
break;
case '▀': // Top half
ctx.fillRect(px, py, CHAR_WIDTH + 0.5, halfH);
break;
case '▄': // Bottom half
ctx.fillRect(px, py + halfH, CHAR_WIDTH + 0.5, halfH);
break;
case '▛': // Top-left + top-right + bottom-left
ctx.fillRect(px, py, CHAR_WIDTH + 0.5, halfH); // top
ctx.fillRect(px, py + halfH, halfW, halfH); // bottom-left
break;
case '▜': // Top-left + top-right + bottom-right
ctx.fillRect(px, py, CHAR_WIDTH + 0.5, halfH); // top
ctx.fillRect(px + halfW, py + halfH, halfW + 0.5, halfH); // bottom-right
break;
case '▙': // Top-left + bottom-left + bottom-right
ctx.fillRect(px, py + halfH, CHAR_WIDTH + 0.5, halfH); // bottom
ctx.fillRect(px, py, halfW, halfH); // top-left
break;
case '▟': // Top-right + bottom-left + bottom-right
ctx.fillRect(px, py + halfH, CHAR_WIDTH + 0.5, halfH); // bottom
ctx.fillRect(px + halfW, py, halfW + 0.5, halfH); // top-right
break;
case '▘': // Top-left quadrant
ctx.fillRect(px, py, halfW, halfH);
break;
case '▝': // Top-right quadrant
ctx.fillRect(px + halfW, py, halfW + 0.5, halfH);
break;
case '▖': // Bottom-left quadrant
ctx.fillRect(px, py + halfH, halfW, halfH);
break;
case '▗': // Bottom-right quadrant
ctx.fillRect(px + halfW, py + halfH, halfW + 0.5, halfH);
break;
case '▓': // Dark shade
case '▒': // Medium shade
case '░': // Light shade
ctx.fillRect(px, py, CHAR_WIDTH + 0.5, CHAR_HEIGHT);
break;
default:
// Regular text character
ctx.fillText(char, px, (y + 1) * CHAR_HEIGHT);
}
}
}
}
}
}
const fb = new FrameBuffer(COLS, ROWS);
function updateProgress(frame, phase = 'Rendering') {
const percent = Math.round((frame / TOTAL_FRAMES) * 100);
document.getElementById('progress').textContent = `${phase}: ${percent}% (${frame}/${TOTAL_FRAMES})`;
}
function renderFrame(frame) {
fb.clear();
mainAnimation(fb, frame);
fb.render();
}
// Check if WebCodecs is available
function hasWebCodecs() {
return typeof VideoEncoder !== 'undefined' && typeof VideoFrame !== 'undefined';
}
// Fast generation using WebCodecs
async function generateWithWebCodecs() {
const status = document.getElementById('status');
status.textContent = 'Generating video...';
const muxer = new Mp4Muxer.Muxer({
target: new Mp4Muxer.ArrayBufferTarget(),
video: {
codec: 'avc',
width: canvas.width,
height: canvas.height
},
fastStart: 'in-memory'
});
let frameCount = 0;
const frameDuration = 1_000_000 / FPS; // microseconds
const encoder = new VideoEncoder({
output: (chunk, meta) => {
muxer.addVideoChunk(chunk, meta);
},
error: (e) => console.error('Encoder error:', e)
});
encoder.configure({
codec: 'avc1.640028',
width: canvas.width,
height: canvas.height,
bitrate: 5_000_000,
framerate: FPS
});
// Render and encode all frames as fast as possible
for (let frame = 0; frame < TOTAL_FRAMES; frame++) {
renderFrame(frame);
updateProgress(frame, 'Encoding');
const videoFrame = new VideoFrame(canvas, {
timestamp: frame * frameDuration,
duration: frameDuration
});
encoder.encode(videoFrame, { keyFrame: frame % 24 === 0 });
videoFrame.close();
frameCount++;
// Yield to UI every 10 frames
if (frame % 10 === 0) {
await new Promise(r => setTimeout(r, 0));
}
}
await encoder.flush();
muxer.finalize();
const buffer = muxer.target.buffer;
const blob = new Blob([buffer], { type: 'video/mp4' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'year_in_review_2025.mp4';
a.click();
URL.revokeObjectURL(url);
return 'mp4';
}
// Fallback: real-time MediaRecorder
async function generateWithMediaRecorder() {
const status = document.getElementById('status');
status.textContent = 'Generating video...';
const stream = canvas.captureStream(FPS);
let options = {};
let fileExtension = 'webm';
if (MediaRecorder.isTypeSupported('video/mp4;codecs=avc1')) {
options = { mimeType: 'video/mp4;codecs=avc1' };
fileExtension = 'mp4';
} else if (MediaRecorder.isTypeSupported('video/webm;codecs=vp9')) {
options = { mimeType: 'video/webm;codecs=vp9' };
} else if (MediaRecorder.isTypeSupported('video/webm')) {
options = { mimeType: 'video/webm' };
}
const chunks = [];
const recorder = new MediaRecorder(stream, options);
recorder.ondataavailable = e => {
if (e.data.size > 0) chunks.push(e.data);
};
const downloadPromise = new Promise(resolve => {
recorder.onstop = () => {
const blob = new Blob(chunks, { type: options.mimeType || 'video/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `year_in_review_2025.${fileExtension}`;
a.click();
URL.revokeObjectURL(url);
resolve();
};
});
recorder.start();
let frame = 0;
await new Promise(resolve => {
const interval = setInterval(() => {
renderFrame(frame);
updateProgress(frame, 'Recording');
frame++;
if (frame >= TOTAL_FRAMES) {
clearInterval(interval);
setTimeout(() => {
recorder.stop();
resolve();
}, 100);
}
}, 1000 / FPS);
});
await downloadPromise;
return fileExtension;
}
async function generateAndDownload() {
const btn = document.getElementById('downloadBtn');
const status = document.getElementById('status');
btn.disabled = true;
const startTime = performance.now();
let fileExtension;
try {
if (hasWebCodecs()) {
fileExtension = await generateWithWebCodecs();
} else {
fileExtension = await generateWithMediaRecorder();
}
} catch (e) {
console.warn('WebCodecs failed, falling back to MediaRecorder:', e);
fileExtension = await generateWithMediaRecorder();
}
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
status.textContent = `Video saved as year_in_review_2025.${fileExtension} (${elapsed}s)`;
document.getElementById('progress').textContent = '';
document.getElementById('reminder').style.display = 'block';
btn.disabled = false;
}
document.getElementById('downloadBtn').addEventListener('click', generateAndDownload);
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,373 @@
/* eslint-disable */
/**
* Year in Review Animation Template
*
* This template uses the SceneManager for guaranteed timing:
* - Scenes are defined in SECONDS (not frames)
* - Each scene has automatic transition in/out phases (~0.5s each)
* - Content is guaranteed time to be absorbed (HOLD phase, default 2s)
* - Total duration is computed automatically
*
* Scene timing breakdown (for a 5s scene with default 2s hold):
* - 0.0s - 0.5s: TRANSITION_IN (content hidden)
* - 0.5s - 2.5s: CONTENT (content animates in, contentProgress 0→1)
* - 2.5s - 4.5s: HOLD (content fully visible, time to read)
* - 4.5s - 5.0s: TRANSITION_OUT (fade out)
*
* Save the customized version as year_in_review.js in this folder.
*
* IMPORTANT: DO NOT USE ES MODULE IMPORTS (import/export statements)
* This file is loaded as a regular <script> tag in year_in_review.html, not as a module.
* All helper functions are available on globalThis from scripts loaded before this one.
*
* To use helper functions, destructure from globalThis at the top of your file:
*
* const {
* // Scene system (REQUIRED)
* SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
* // Backgrounds
* stars, fireflies, dust, snow, fog, aurora, waves, rain,
* // Particles
* floatingParticles, embers, sparkles, confetti, hearts,
* // Transitions
* dissolve, circleReveal, circleClose, fade, wipeLeft, wipeRight, irisOut,
* // Text effects
* drawTypewriterCentered, drawFadeInText, slideIn, drawGlitchText,
* } = globalThis;
*
* See helpers/index.js for the complete list of available functions.
*/
// =============================================================================
// HELPER DESTRUCTURING (must come BEFORE SceneManager usage)
// =============================================================================
// Get helpers from globalThis - SceneManager MUST be destructured before use
const {
// Scene system (REQUIRED - must be first)
SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
// Backgrounds
stars, fireflies, dust, snow, fog, aurora, waves, rain,
// Particles
floatingParticles, embers, sparkles, confetti, hearts,
// Transitions
dissolve, circleReveal, circleClose, fade, wipeLeft, wipeRight, irisOut,
// Text effects
drawTypewriterCentered, drawFadeInText, slideIn, drawGlitchText,
// Claude branding & intro (REQUIRED for intro scene)
drawThinkbackIntro, CLAUDE_ORANGE,
} = globalThis;
// =============================================================================
// SCENE DEFINITIONS (in seconds)
// =============================================================================
// Define your scenes with durations in SECONDS
// The SceneManager will compute frame ranges automatically
//
// Each scene has:
// - duration: Total scene length in seconds
// - hold: (optional) Time content stays fully visible before transition out (default: 2s)
//
// The system guarantees:
// - ~0.5s transition in
// - Content animation phase (remaining time after transitions and hold)
// - Your specified hold time (default 2s)
// - ~0.5s transition out
//
// IMPORTANT: The first scene MUST be 'thinkback_intro' using drawThinkbackIntro()
const SCENE_DEFINITIONS = [
{ name: 'thinkback_intro', duration: 7, hold: 2 }, // REQUIRED: Branded intro with Clawd & logo
{ name: 'stats', duration: 6, hold: 2.5 }, // 2.5s hold for more stats
{ name: 'projects', duration: 6, hold: 2.5 }, // Project showcase scene
{ name: 'closing', duration: 4, hold: 1.5 }, // 1.5s hold (shorter scene)
];
// =============================================================================
// USER PERSONALIZATION (customize these based on stats analysis)
// =============================================================================
// Personalization for the intro scene - customize based on user's stats!
// Examples:
// - Night owl: { userName: 'the midnight coder', tagline: 'burning the midnight oil' }
// - Prolific: { userName: 'the prolific builder', tagline: '1,234 commits and counting' }
// - Explorer: { userName: '@username', tagline: 'your year across the codebase' }
const USER_INTRO = {
userName: 'you', // Replace with user's name or creative handle
year: 2025, // Year being reviewed
tagline: 'your year with Claude', // Optional tagline based on their story
};
// Create the scene manager - this computes TOTAL_FRAMES automatically
const sceneManager = new SceneManager(SCENE_DEFINITIONS);
const TOTAL_FRAMES = sceneManager.getTotalFrames();
// =============================================================================
// ANIMATION UTILITIES (provided by scene_system.js)
// =============================================================================
// These are now available from globalThis:
// - easeInOut(t) - smooth ease in/out
// - easeOut(t) - fast start, slow end
// - animateCounter(target, progress) - animate a number from 0 to target
// - staggeredReveal(count, overlap) - create staggered timing for lists
// Seeded random - returns consistent values for the same seed
function seededRandom(seed) {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
// Figlet-style font for large ASCII text (5 rows high, 5 chars wide)
const FIGLET_FONT = {
'0': [' @@@ ', '@ @', '@ @', '@ @', ' @@@ '],
'1': [' @ ', ' @@ ', ' @ ', ' @ ', ' @@@ '],
'2': [' @@@ ', '@ @', ' @@ ', ' @ ', '@@@@@'],
'3': [' @@@ ', '@ @', ' @@ ', '@ @', ' @@@ '],
'4': ['@ @', '@ @', '@@@@@', ' @', ' @'],
'5': ['@@@@@', '@ ', '@@@@ ', ' @', '@@@@ '],
'6': [' @@@ ', '@ ', '@@@@ ', '@ @', ' @@@ '],
'7': ['@@@@@', ' @', ' @ ', ' @ ', ' @ '],
'8': [' @@@ ', '@ @', ' @@@ ', '@ @', ' @@@ '],
'9': [' @@@ ', '@ @', ' @@@@', ' @', ' @@@ '],
'A': [' @ ', ' @ @ ', '@@@@@', '@ @', '@ @'],
'B': ['@@@@ ', '@ @', '@@@@ ', '@ @', '@@@@ '],
'C': [' @@@@', '@ ', '@ ', '@ ', ' @@@@'],
'D': ['@@@@ ', '@ @', '@ @', '@ @', '@@@@ '],
'E': ['@@@@@', '@ ', '@@@@', '@ ', '@@@@@'],
'F': ['@@@@@', '@ ', '@@@@ ', '@ ', '@ '],
'G': [' @@@@', '@ ', '@ @@', '@ @', ' @@@ '],
'H': ['@ @', '@ @', '@@@@@', '@ @', '@ @'],
'I': [' @@@ ', ' @ ', ' @ ', ' @ ', ' @@@ '],
'J': [' @@@', ' @', ' @', '@ @', ' @@@ '],
'K': ['@ @', '@ @ ', '@@ ', '@ @ ', '@ @'],
'L': ['@ ', '@ ', '@ ', '@ ', '@@@@@'],
'M': ['@ @', '@@ @@', '@ @ @', '@ @', '@ @'],
'N': ['@ @', '@@ @', '@ @ @', '@ @@', '@ @'],
'O': [' @@@ ', '@ @', '@ @', '@ @', ' @@@ '],
'P': ['@@@@ ', '@ @', '@@@@ ', '@ ', '@ '],
'Q': [' @@@ ', '@ @', '@ @ @', '@ @ ', ' @@ @'],
'R': ['@@@@ ', '@ @', '@@@@ ', '@ @ ', '@ @'],
'S': [' @@@@', '@ ', ' @@@ ', ' @', '@@@@ '],
'T': ['@@@@@', ' @ ', ' @ ', ' @ ', ' @ '],
'U': ['@ @', '@ @', '@ @', '@ @', ' @@@ '],
'V': ['@ @', '@ @', '@ @', ' @ @ ', ' @ '],
'W': ['@ @', '@ @', '@ @ @', '@@ @@', '@ @'],
'X': ['@ @', ' @ @ ', ' @ ', ' @ @ ', '@ @'],
'Y': ['@ @', ' @ @ ', ' @ ', ' @ ', ' @ '],
'Z': ['@@@@@', ' @ ', ' @ ', ' @ ', '@@@@@'],
' ': [' ', ' ', ' ', ' ', ' '],
};
// =============================================================================
// SCENE RENDERERS
// =============================================================================
/**
* Each scene renderer receives:
* - fb: Framebuffer with drawing methods
* - frame: Global frame number (for animations)
* - scene: Scene info from SceneManager:
* - name: Scene name
* - phase: 'TRANSITION_IN' | 'CONTENT' | 'HOLD' | 'TRANSITION_OUT'
* - contentProgress: 0-1, progress through CONTENT phase
* - transitionProgress: 0-1, progress through current transition
*/
/**
* REQUIRED: Thinkback intro scene with Clawd, Claude Code logo, and personalized text.
* This scene MUST be the first scene in every Thinkback animation.
*/
function renderThinkbackIntro(fb, frame, scene) {
// Starfield background
stars(fb, frame, { density: 0.012, twinkle: true });
// Calculate overall progress including hold phase
let p = 0;
if (scene.phase === 'TRANSITION_IN') {
p = scene.transitionProgress * 0.1; // Start slowly during transition in
} else if (scene.phase === 'CONTENT') {
p = 0.1 + scene.contentProgress * 0.7; // Main content animation
} else if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
p = 1; // Fully visible during hold
}
// Draw the branded intro with all elements
drawThinkbackIntro(fb, frame, p, USER_INTRO);
// Add sparkles during hold and transition out for celebration
if (scene.phase === 'HOLD' || scene.phase === 'TRANSITION_OUT') {
sparkles(fb, frame, { density: 0.004 });
}
// Transition out
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderStats(fb, frame, scene) {
stars(fb, frame, { density: 0.003 });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
fb.drawCenteredText(6, '=== Your Stats ===');
// Use staggered reveal for list items
const reveal = globalThis.staggeredReveal(3, 0.5);
const stats = [
{ label: 'Commits', value: 100 },
{ label: 'Sessions', value: 500 },
{ label: 'Messages', value: 2000 },
];
stats.forEach((stat, i) => {
const itemP = reveal(p, i);
if (itemP > 0) {
const count = globalThis.animateCounter(stat.value, itemP);
fb.drawCenteredText(10 + i * 3, `${stat.label}: ${count.toLocaleString()}`);
}
});
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
/**
* Example scene showing project highlights
*/
function renderProjects(fb, frame, scene) {
stars(fb, frame, { density: 0.003 });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
fb.drawCenteredText(6, '=== Your Projects ===');
// Example project list - in real usage, this would be populated from stats
const projects = [
{ name: 'claude-code', commits: 42 },
{ name: 'awesome-project', commits: 28 },
{ name: 'open-source-lib', commits: 15 },
{ name: 'side-project', commits: 8 },
];
const reveal = globalThis.staggeredReveal(projects.length, 0.4);
projects.forEach((project, i) => {
const itemP = reveal(p, i);
if (itemP > 0) {
const y = 10 + i * 2;
const text = `${project.name} (${project.commits} commits)`;
fb.drawCenteredText(y, text);
}
});
}
if (scene.phase === 'TRANSITION_OUT') {
dissolve(fb, scene.transitionProgress, frame);
}
}
function renderClosing(fb, frame, scene) {
stars(fb, frame, { density: 0.01, twinkle: true });
if (scene.phase !== 'TRANSITION_IN') {
const p = scene.contentProgress;
if (p > 0.2) {
fb.drawCenteredText(Math.floor(fb.height / 2) - 2, 'Thank you');
}
if (p > 0.5) {
fb.drawCenteredText(Math.floor(fb.height / 2) + 2, 'See you next year!');
}
sparkles(fb, frame, { density: 0.006 });
}
// Final fade to black
if (scene.phase === 'TRANSITION_OUT') {
const fadeP = 1 - scene.transitionProgress;
for (let y = 0; y < fb.height; y++) {
for (let x = 0; x < fb.width; x++) {
if (Math.random() < fadeP * 0.5) {
fb.setPixel(x, y, ' ', -100);
}
}
}
}
}
// Map scene names to renderers
const SCENE_RENDERERS = {
thinkback_intro: renderThinkbackIntro,
stats: renderStats,
projects: renderProjects,
closing: renderClosing,
};
// =============================================================================
// MAIN ANIMATION FUNCTION
// =============================================================================
/**
* Main animation function - called once per frame
*
* @param {Object} fb - Framebuffer with drawing methods
* @param {number} frame - Current frame number (0 to TOTAL_FRAMES-1)
*/
function mainAnimation(fb, frame) {
// Get current scene info from the manager
const scene = sceneManager.getSceneAt(frame);
if (!scene) {
fb.drawCenteredText(Math.floor(fb.height / 2), 'Animation complete');
return;
}
// Call the appropriate scene renderer
const renderer = SCENE_RENDERERS[scene.name];
if (renderer) {
renderer(fb, frame, scene);
}
}
/**
* Returns the name of the current scene (shown in player UI)
*/
function getSceneName(frame) {
const scene = sceneManager.getSceneAt(frame);
if (!scene) return 'Complete';
const names = {
thinkback_intro: 'Think Back',
stats: 'The Stats',
projects: 'Your Projects',
closing: 'Closing',
};
return names[scene.name] || scene.name;
}
// =============================================================================
// EXPORTS - CRITICAL: Must use this exact format!
// =============================================================================
// The year_in_review.html expects globalThis.YearInReviewScenes with mainAnimation.
// DO NOT use: globalThis.render = render; globalThis.TOTAL_FRAMES = ...;
// That pattern will cause "YearInReviewScenes not loaded" errors.
globalThis.YearInReviewScenes = {
TOTAL_FRAMES,
FIGLET_FONT,
easeInOut: globalThis.easeInOut,
seededRandom,
mainAnimation,
getSceneName,
// Export scene manager for debugging
sceneManager,
};