diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 68e6526..62b23a5 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -139,6 +139,17 @@
"category": "development",
"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",
"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.",
diff --git a/plugins/thinkback/.claude-plugin/plugin.json b/plugins/thinkback/.claude-plugin/plugin.json
new file mode 100644
index 0000000..00152c3
--- /dev/null
+++ b/plugins/thinkback/.claude-plugin/plugin.json
@@ -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"
+ }
+}
diff --git a/plugins/thinkback/README.md b/plugins/thinkback/README.md
new file mode 100644
index 0000000..068af00
--- /dev/null
+++ b/plugins/thinkback/README.md
@@ -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)
diff --git a/plugins/thinkback/package.json b/plugins/thinkback/package.json
new file mode 100644
index 0000000..3dbc1ca
--- /dev/null
+++ b/plugins/thinkback/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "module"
+}
diff --git a/plugins/thinkback/skills/thinkback/SKILL.md b/plugins/thinkback/skills/thinkback/SKILL.md
new file mode 100644
index 0000000..1b89cba
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/SKILL.md
@@ -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
\ No newline at end of file
diff --git a/plugins/thinkback/skills/thinkback/ascii_anim.js b/plugins/thinkback/skills/thinkback/ascii_anim.js
new file mode 100644
index 0000000..4b125e0
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/ascii_anim.js
@@ -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
+};
diff --git a/plugins/thinkback/skills/thinkback/helpers/awards_effects.js b/plugins/thinkback/skills/thinkback/helpers/awards_effects.js
new file mode 100644
index 0000000..020df98
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/helpers/awards_effects.js
@@ -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,
+ });
+}
+})();
diff --git a/plugins/thinkback/skills/thinkback/helpers/backgrounds.js b/plugins/thinkback/skills/thinkback/helpers/backgrounds.js
new file mode 100644
index 0000000..2f6e7c1
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/helpers/backgrounds.js
@@ -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
+ });
+}
+})();
diff --git a/plugins/thinkback/skills/thinkback/helpers/borders.js b/plugins/thinkback/skills/thinkback/helpers/borders.js
new file mode 100644
index 0000000..5ab4a51
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/helpers/borders.js
@@ -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
+ });
+}
+})();
diff --git a/plugins/thinkback/skills/thinkback/helpers/index.js b/plugins/thinkback/skills/thinkback/helpers/index.js
new file mode 100644
index 0000000..1281213
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/helpers/index.js
@@ -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;
diff --git a/plugins/thinkback/skills/thinkback/helpers/news_effects.js b/plugins/thinkback/skills/thinkback/helpers/news_effects.js
new file mode 100644
index 0000000..cbff9ed
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/helpers/news_effects.js
@@ -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,
+ });
+}
+})();
diff --git a/plugins/thinkback/skills/thinkback/helpers/particles.js b/plugins/thinkback/skills/thinkback/helpers/particles.js
new file mode 100644
index 0000000..f4874d8
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/helpers/particles.js
@@ -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
+ });
+}
+})();
diff --git a/plugins/thinkback/skills/thinkback/helpers/rpg_effects.js b/plugins/thinkback/skills/thinkback/helpers/rpg_effects.js
new file mode 100644
index 0000000..a8c6ef2
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/helpers/rpg_effects.js
@@ -0,0 +1,1181 @@
+/* eslint-disable */
+/**
+ * RPG Quest Effects
+ * Specialized effects for the RPG quest 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;
+}
+
+// Seeded random for consistent effects
+function seededRandom(seed) {
+ const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
+ return x - Math.floor(x);
+}
+
+// ============================================================================
+// CHARACTER SPRITES - Simple ASCII character art
+// ============================================================================
+
+const SPRITES = {
+ FEATURE_CRAFTER: [
+ ' ◉◡◉ ',
+ ' /|\\ ',
+ ' / \\ ',
+ ],
+ BUG_SLAYER: [
+ ' ⚔◡⚔ ',
+ '|\\>',
+ ' / \\ ',
+ ],
+ DOCS_WIZARD: [
+ ' ◉_◉ ',
+ '(|☆|)',
+ ' /|\\ ',
+ ],
+ REFACTOR_KNIGHT: [
+ ' [◡] ',
+ '╔|█|╗',
+ ' / \\ ',
+ ],
+ FULL_STACK_PALADIN: [
+ ' ✧◡✧ ',
+ '⌐|█|¬',
+ ' ╱ ╲ ',
+ ],
+ SPEED_DEMON: [
+ ' ≫◡≪ ',
+ '»/|\\«',
+ ' » « ',
+ ],
+ DEEP_DELVER: [
+ ' ◎_◎ ',
+ ' \\|/ ',
+ ' _╱╲_',
+ ],
+};
+
+const IDLE_FRAMES = [
+ [' ◉◡◉ ', ' /|\\ ', ' / \\ '],
+ [' ◉◡◉ ', ' /|\\/', ' / \\ '],
+ [' ◉◡◉ ', '\\/|\\ ', ' / \\ '],
+];
+
+/**
+ * Draw a character sprite
+ * @param fb - Framebuffer
+ * @param x - X position (center)
+ * @param y - Y position (top)
+ * @param options - { class, animate }
+ * @param frame - Current frame for animation
+ */
+function characterSprite(fb, x, y, options, frame) {
+ const { class: charClass = 'FEATURE_CRAFTER', animate = false, depth = 5 } = options;
+
+ let sprite = SPRITES[charClass] || SPRITES.FEATURE_CRAFTER;
+
+ // Simple idle animation
+ if (animate) {
+ const animFrame = Math.floor(frame / 10) % IDLE_FRAMES.length;
+ sprite = IDLE_FRAMES[animFrame];
+ }
+
+ const spriteWidth = Math.max(...sprite.map(line => line.length));
+ const startX = x - Math.floor(spriteWidth / 2);
+
+ for (let i = 0; i < sprite.length; i++) {
+ const line = sprite[i];
+ for (let j = 0; j < line.length; j++) {
+ if (line[j] !== ' ') {
+ fb.setPixel(startX + j, y + i, line[j], depth + 2);
+ }
+ }
+ }
+
+ return sprite.length;
+}
+
+// ============================================================================
+// TITLE SCREEN - Classic RPG title
+// ============================================================================
+
+/**
+ * Draw a classic RPG title screen
+ * @param fb - Framebuffer
+ * @param options - { title, subtitle, prompt }
+ * @param progress - Animation progress 0-1
+ * @param frame - Current frame for blink effect
+ */
+function titleScreen(fb, options, progress, frame) {
+ const { title = 'ADVENTURE', subtitle = '2024', prompt = 'PRESS START', depth = 5 } = options;
+
+ const centerX = Math.floor(fb.width / 2);
+ const centerY = Math.floor(fb.height / 2);
+ const easedProgress = easeOut(progress);
+
+ // Draw decorative box
+ const boxWidth = Math.max(title.length, subtitle.length, prompt.length) + 10;
+ const boxHeight = 9;
+ const boxX = centerX - Math.floor(boxWidth / 2);
+ const boxY = centerY - Math.floor(boxHeight / 2);
+
+ if (easedProgress > 0.1) {
+ const visibleWidth = Math.floor(((easedProgress - 0.1) / 0.3) * boxWidth);
+
+ // Top border
+ fb.setPixel(boxX, boxY, '╔', depth);
+ for (let i = 1; i < Math.min(visibleWidth - 1, boxWidth - 1); i++) {
+ fb.setPixel(boxX + i, boxY, '═', depth);
+ }
+ if (visibleWidth >= boxWidth) fb.setPixel(boxX + boxWidth - 1, boxY, '╗', depth);
+
+ // Sides
+ for (let row = 1; row < boxHeight - 1; row++) {
+ fb.setPixel(boxX, boxY + row, '║', depth);
+ if (visibleWidth >= boxWidth) fb.setPixel(boxX + boxWidth - 1, boxY + row, '║', depth);
+ }
+
+ // Bottom border
+ if (easedProgress > 0.4) {
+ fb.setPixel(boxX, boxY + boxHeight - 1, '╚', depth);
+ for (let i = 1; i < boxWidth - 1; i++) {
+ fb.setPixel(boxX + i, boxY + boxHeight - 1, '═', depth);
+ }
+ fb.setPixel(boxX + boxWidth - 1, boxY + boxHeight - 1, '╝', depth);
+ }
+ }
+
+ // Title text
+ if (easedProgress > 0.3) {
+ const titleProgress = Math.min(1, (easedProgress - 0.3) / 0.2);
+ const visibleTitle = title.slice(0, Math.floor(titleProgress * title.length));
+ const titleX = centerX - Math.floor(title.length / 2);
+
+ for (let i = 0; i < visibleTitle.length; i++) {
+ fb.setPixel(titleX + i, centerY - 2, visibleTitle[i], depth + 3);
+ }
+ }
+
+ // Subtitle
+ if (easedProgress > 0.5) {
+ const subProgress = Math.min(1, (easedProgress - 0.5) / 0.15);
+ const visibleSub = subtitle.slice(0, Math.floor(subProgress * subtitle.length));
+ const subX = centerX - Math.floor(subtitle.length / 2);
+
+ for (let i = 0; i < visibleSub.length; i++) {
+ fb.setPixel(subX + i, centerY, visibleSub[i], depth + 2);
+ }
+ }
+
+ // Blinking prompt
+ if (easedProgress > 0.7) {
+ const showPrompt = Math.floor(frame / 20) % 2 === 0;
+ if (showPrompt) {
+ const promptX = centerX - Math.floor(prompt.length / 2);
+ for (let i = 0; i < prompt.length; i++) {
+ fb.setPixel(promptX + i, centerY + 3, prompt[i], depth + 1);
+ }
+ }
+ }
+}
+
+// ============================================================================
+// TEXT BOX - Classic RPG dialog box
+// ============================================================================
+
+/**
+ * Draw an RPG-style text box with character-by-character reveal
+ * @param fb - Framebuffer
+ * @param y - Y position
+ * @param text - Text to display
+ * @param progress - Animation progress 0-1
+ * @param frame - Current frame for cursor blink
+ * @param options - { width, style, speaker }
+ */
+function textBox(fb, y, text, progress, frame, options = {}) {
+ const {
+ width = 50,
+ style = 'rpg',
+ speaker = '',
+ depth = 5,
+ centered = true,
+ } = options;
+
+ const easedProgress = easeOut(progress);
+ const centerX = Math.floor(fb.width / 2);
+ const x = centered ? centerX - Math.floor(width / 2) : 2;
+
+ // Box characters based on style
+ const chars = style === 'rpg'
+ ? { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' }
+ : style === 'modern'
+ ? { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' }
+ : { tl: '[', tr: ']', bl: '[', br: ']', h: '-', v: '|' };
+
+ // Draw top border with optional speaker label
+ if (speaker) {
+ fb.setPixel(x, y, chars.tl, depth);
+ fb.setPixel(x + 1, y, chars.h, depth);
+ fb.setPixel(x + 2, y, ' ', depth);
+ for (let i = 0; i < speaker.length; i++) {
+ fb.setPixel(x + 3 + i, y, speaker[i], depth + 1);
+ }
+ fb.setPixel(x + 3 + speaker.length, y, ' ', depth);
+ for (let i = 4 + speaker.length; i < width - 1; i++) {
+ fb.setPixel(x + i, y, chars.h, depth);
+ }
+ fb.setPixel(x + width - 1, y, chars.tr, depth);
+ } else {
+ fb.setPixel(x, y, chars.tl, depth);
+ for (let i = 1; i < width - 1; i++) {
+ fb.setPixel(x + i, y, chars.h, depth);
+ }
+ fb.setPixel(x + width - 1, y, chars.tr, depth);
+ }
+
+ // Text area (word wrap)
+ const maxTextWidth = width - 4;
+ const words = text.split(' ');
+ const lines = [];
+ let currentLine = '';
+
+ for (const word of words) {
+ if ((currentLine + ' ' + word).trim().length > maxTextWidth) {
+ if (currentLine) lines.push(currentLine);
+ currentLine = word;
+ } else {
+ currentLine = currentLine ? currentLine + ' ' + word : word;
+ }
+ }
+ if (currentLine) lines.push(currentLine);
+
+ // Calculate visible characters
+ const totalChars = lines.join('').length;
+ const visibleChars = Math.floor(easedProgress * totalChars);
+ let charsDrawn = 0;
+
+ // Draw text lines
+ let currentY = y + 1;
+ for (const line of lines) {
+ fb.setPixel(x, currentY, chars.v, depth);
+
+ const lineCharsToShow = Math.min(line.length, visibleChars - charsDrawn);
+ if (lineCharsToShow > 0) {
+ const visibleLine = line.slice(0, lineCharsToShow);
+ for (let i = 0; i < visibleLine.length; i++) {
+ fb.setPixel(x + 2 + i, currentY, visibleLine[i], depth);
+ }
+
+ // Blinking cursor at end of visible text
+ if (charsDrawn + lineCharsToShow < totalChars && easedProgress < 1) {
+ const cursorChar = Math.floor(frame / 6) % 2 === 0 ? '▼' : ' ';
+ fb.setPixel(x + 2 + lineCharsToShow, currentY, cursorChar, depth);
+ }
+ }
+
+ // Fill rest with spaces
+ for (let i = Math.max(0, lineCharsToShow) + 2; i < width - 1; i++) {
+ fb.setPixel(x + i, currentY, ' ', depth);
+ }
+
+ fb.setPixel(x + width - 1, currentY, chars.v, depth);
+ charsDrawn += line.length;
+ currentY++;
+
+ if (charsDrawn >= visibleChars) break;
+ }
+
+ // Add empty rows if we haven't filled enough
+ const minRows = 2;
+ while (currentY < y + 1 + minRows) {
+ fb.setPixel(x, currentY, chars.v, depth);
+ for (let i = 1; i < width - 1; i++) {
+ fb.setPixel(x + i, currentY, ' ', depth);
+ }
+ fb.setPixel(x + width - 1, currentY, chars.v, depth);
+ currentY++;
+ }
+
+ // Continuation indicator
+ if (easedProgress >= 1) {
+ const blinkIndicator = Math.floor(frame / 8) % 2 === 0 ? '▼' : ' ';
+ fb.setPixel(x + width - 3, currentY - 1, blinkIndicator, depth);
+ }
+
+ // Bottom border
+ fb.setPixel(x, currentY, chars.bl, depth);
+ for (let i = 1; i < width - 1; i++) {
+ fb.setPixel(x + i, currentY, chars.h, depth);
+ }
+ fb.setPixel(x + width - 1, currentY, chars.br, depth);
+
+ return currentY - y + 1;
+}
+
+// ============================================================================
+// CLASS SELECT - Character class display
+// ============================================================================
+
+/**
+ * Draw a character class selection/display screen
+ * @param fb - Framebuffer
+ * @param options - { className, description, stats, traits }
+ * @param progress - Animation progress 0-1
+ * @param frame - Current frame for animation
+ * @param displayOptions - { y, showSprite }
+ */
+function classSelect(fb, options, progress, frame, displayOptions = {}) {
+ const {
+ className = 'ADVENTURER',
+ description = 'A brave soul',
+ stats = {},
+ traits = [],
+ } = options;
+
+ const { y = 5, showSprite = true, depth = 5 } = displayOptions;
+
+ const centerX = Math.floor(fb.width / 2);
+ const easedProgress = easeOut(progress);
+
+ let currentY = y;
+
+ // Draw sprite
+ if (showSprite && easedProgress > 0.1) {
+ const spriteProgress = Math.min(1, (easedProgress - 0.1) / 0.2);
+ if (spriteProgress > 0.5) {
+ // Draw a box around sprite
+ const spriteBoxWidth = 11;
+ const spriteBoxX = centerX - Math.floor(spriteBoxWidth / 2);
+
+ fb.setPixel(spriteBoxX, currentY, '╭', depth);
+ for (let i = 1; i < spriteBoxWidth - 1; i++) {
+ fb.setPixel(spriteBoxX + i, currentY, '─', depth);
+ }
+ fb.setPixel(spriteBoxX + spriteBoxWidth - 1, currentY, '╮', depth);
+ currentY++;
+
+ // Sprite lines
+ const spriteKey = className.replace(/ /g, '_').toUpperCase();
+ const sprite = SPRITES[spriteKey] || SPRITES.FEATURE_CRAFTER;
+
+ for (const line of sprite) {
+ fb.setPixel(spriteBoxX, currentY, '│', depth);
+ const lineX = spriteBoxX + Math.floor((spriteBoxWidth - line.length) / 2);
+ for (let i = 0; i < line.length; i++) {
+ if (line[i] !== ' ') {
+ fb.setPixel(lineX + i, currentY, line[i], depth + 2);
+ }
+ }
+ fb.setPixel(spriteBoxX + spriteBoxWidth - 1, currentY, '│', depth);
+ currentY++;
+ }
+
+ fb.setPixel(spriteBoxX, currentY, '╰', depth);
+ for (let i = 1; i < spriteBoxWidth - 1; i++) {
+ fb.setPixel(spriteBoxX + i, currentY, '─', depth);
+ }
+ fb.setPixel(spriteBoxX + spriteBoxWidth - 1, currentY, '╯', depth);
+ currentY += 2;
+ }
+ }
+
+ // Class name
+ if (easedProgress > 0.3) {
+ const nameProgress = Math.min(1, (easedProgress - 0.3) / 0.15);
+ const visibleName = className.slice(0, Math.floor(nameProgress * className.length));
+ const nameX = centerX - Math.floor(className.length / 2);
+
+ for (let i = 0; i < visibleName.length; i++) {
+ fb.setPixel(nameX + i, currentY, visibleName[i], depth + 3);
+ }
+ currentY++;
+ }
+
+ // Description in quotes
+ if (description && easedProgress > 0.4) {
+ const descProgress = Math.min(1, (easedProgress - 0.4) / 0.15);
+ const fullDesc = `"${description}"`;
+ const visibleDesc = fullDesc.slice(0, Math.floor(descProgress * fullDesc.length));
+ const descX = centerX - Math.floor(fullDesc.length / 2);
+
+ for (let i = 0; i < visibleDesc.length; i++) {
+ fb.setPixel(descX + i, currentY, visibleDesc[i], depth + 1);
+ }
+ currentY += 2;
+ }
+
+ // Stats bars
+ if (stats && Object.keys(stats).length > 0 && easedProgress > 0.5) {
+ const statsProgress = Math.min(1, (easedProgress - 0.5) / 0.25);
+ const statEntries = Object.entries(stats);
+ const maxStatWidth = 30;
+ const statsX = centerX - Math.floor(maxStatWidth / 2);
+
+ for (const [statName, statValue] of statEntries) {
+ // Stat label
+ const labelWidth = 4;
+ const label = statName.slice(0, labelWidth).padEnd(labelWidth);
+ for (let i = 0; i < label.length; i++) {
+ fb.setPixel(statsX + i, currentY, label[i], depth);
+ }
+
+ // Stat bar
+ const barWidth = 15;
+ const maxStat = 10;
+ const filledWidth = Math.floor(statsProgress * (statValue / maxStat) * barWidth);
+
+ for (let i = 0; i < barWidth; i++) {
+ const char = i < filledWidth ? '█' : '░';
+ fb.setPixel(statsX + labelWidth + 1 + i, currentY, char, depth + 1);
+ }
+
+ // Stat value
+ const valueStr = ` ${statValue}`;
+ for (let i = 0; i < valueStr.length; i++) {
+ fb.setPixel(statsX + labelWidth + 1 + barWidth + i, currentY, valueStr[i], depth);
+ }
+
+ currentY++;
+ }
+ currentY++;
+ }
+
+ // Traits
+ if (traits && traits.length > 0 && easedProgress > 0.75) {
+ const traitProgress = Math.min(1, (easedProgress - 0.75) / 0.2);
+ const visibleTraits = Math.floor(traitProgress * traits.length);
+
+ const traitStr = traits.slice(0, visibleTraits).map(t => `[${t}]`).join(' ');
+ const traitX = centerX - Math.floor(traitStr.length / 2);
+
+ for (let i = 0; i < traitStr.length; i++) {
+ fb.setPixel(traitX + i, currentY, traitStr[i], depth + 1);
+ }
+ }
+}
+
+// ============================================================================
+// QUEST CARD - Project spotlight for RPG vibe
+// ============================================================================
+
+/**
+ * Draw a full quest completion card 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, showRewards }
+ */
+function questCard(fb, project, progress, frame, options = {}) {
+ const {
+ y = 3,
+ width = 55,
+ showRewards = 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 x = centered ? centerX - Math.floor(width / 2) : 2;
+
+ let currentY = y;
+ const visibleWidth = Math.floor(easedProgress * width);
+ if (visibleWidth < 5) return;
+
+ // 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);
+ }
+ 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++;
+
+ // Quest complete banner with sparkle animation
+ if (easedProgress > 0.1) {
+ const sparkleChars = ['★', '✦', '·', '✧'];
+ const sparkleIdx = Math.floor(frame / 5) % sparkleChars.length;
+ const sparkle = sparkleChars[sparkleIdx];
+ drawRow(` ${sparkle} QUEST COMPLETE ${sparkle}`, depth + 2);
+ }
+
+ // Divider
+ if (easedProgress > 0.15) {
+ 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++;
+ }
+
+ // Empty row
+ drawRow('', depth);
+
+ // Quest/Project name
+ if (easedProgress > 0.2) {
+ const nameProgress = Math.min(1, (easedProgress - 0.2) / 0.15);
+ const visibleName = name.slice(0, Math.floor(nameProgress * name.length));
+ drawRow(` ${visibleName}`, depth + 3);
+ }
+
+ // Description in quotes (quest flavor text)
+ if (description && easedProgress > 0.35) {
+ const descProgress = Math.min(1, (easedProgress - 0.35) / 0.1);
+ const fullDesc = `"${description}"`;
+ const visibleDesc = fullDesc.slice(0, Math.floor(descProgress * fullDesc.length));
+ drawRow(` ${visibleDesc}`, depth + 1);
+ }
+
+ // Divider line
+ if (easedProgress > 0.4) {
+ fb.setPixel(x, currentY, '║', depth);
+ for (let i = 1; i < Math.min(visibleWidth - 1, width - 10); i++) {
+ fb.setPixel(x + i, currentY, '─', depth);
+ }
+ for (let i = width - 10; i < visibleWidth - 1; i++) {
+ fb.setPixel(x + i, currentY, ' ', depth);
+ }
+ if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '║', depth);
+ currentY++;
+ }
+
+ // Body text (quest story)
+ if (body && easedProgress > 0.45) {
+ const bodyProgress = Math.min(1, (easedProgress - 0.45) / 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);
+
+ 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;
+ }
+ }
+
+ // Empty row before rewards
+ drawRow('', depth);
+
+ // Rewards box
+ if (showRewards && easedProgress > 0.75) {
+ const rewardsProgress = Math.min(1, (easedProgress - 0.75) / 0.2);
+
+ // Rewards header
+ fb.setPixel(x, currentY, '║', depth);
+ fb.setPixel(x + 2, currentY, '┌', depth);
+ fb.setPixel(x + 3, currentY, '─', depth);
+ const rewardLabel = ' REWARDS ';
+ for (let i = 0; i < rewardLabel.length; i++) {
+ fb.setPixel(x + 4 + i, currentY, rewardLabel[i], depth + 1);
+ }
+ for (let i = 4 + rewardLabel.length; i < visibleWidth - 4; i++) {
+ fb.setPixel(x + i, currentY, '─', depth);
+ }
+ fb.setPixel(x + visibleWidth - 3, currentY, '┐', depth);
+ for (let i = visibleWidth - 2; i < visibleWidth - 1; i++) {
+ fb.setPixel(x + i, currentY, ' ', depth);
+ }
+ if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '║', depth);
+ currentY++;
+
+ // Rewards content
+ const xpGained = Math.floor(rewardsProgress * commits);
+ const skills = Math.floor(commits / 100) + 1;
+ const rarity = commits > 200 ? 'Legendary' : commits > 100 ? 'Epic' : commits > 50 ? 'Rare' : 'Common';
+ const itemLabel = `+1 ${rarity} Item`;
+
+ const rewardLine = ` +${xpGained.toLocaleString()} XP +${skills} Skills ${itemLabel} `;
+
+ fb.setPixel(x, currentY, '║', depth);
+ fb.setPixel(x + 2, currentY, '│', depth);
+ for (let i = 0; i < rewardLine.length && i < visibleWidth - 6; i++) {
+ fb.setPixel(x + 3 + i, currentY, rewardLine[i], depth + 2);
+ }
+ for (let i = rewardLine.length + 3; i < visibleWidth - 3; i++) {
+ fb.setPixel(x + i, currentY, ' ', depth);
+ }
+ fb.setPixel(x + visibleWidth - 3, currentY, '│', depth);
+ for (let i = visibleWidth - 2; i < visibleWidth - 1; i++) {
+ fb.setPixel(x + i, currentY, ' ', depth);
+ }
+ if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '║', depth);
+ currentY++;
+
+ // Rewards bottom
+ fb.setPixel(x, currentY, '║', depth);
+ fb.setPixel(x + 2, currentY, '└', depth);
+ for (let i = 3; i < visibleWidth - 3; i++) {
+ fb.setPixel(x + i, currentY, '─', depth);
+ }
+ fb.setPixel(x + visibleWidth - 3, currentY, '┘', depth);
+ for (let i = visibleWidth - 2; i < visibleWidth - 1; i++) {
+ fb.setPixel(x + i, currentY, ' ', depth);
+ }
+ if (visibleWidth > 1) fb.setPixel(x + visibleWidth - 1, currentY, '║', depth);
+ currentY++;
+ }
+
+ // Bottom border
+ if (easedProgress > 0.9) {
+ 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);
+ }
+}
+
+// ============================================================================
+// QUEST BANNER - Animated quest announcement
+// ============================================================================
+
+/**
+ * Draw an animated quest banner
+ * @param fb - Framebuffer
+ * @param text - Banner text
+ * @param progress - Animation progress 0-1
+ * @param frame - Current frame for effects
+ * @param options - { y, style }
+ */
+function questBanner(fb, text, progress, frame, options = {}) {
+ const { y = 2, style = 'fanfare', depth = 5 } = options;
+
+ const centerX = Math.floor(fb.width / 2);
+ const easedProgress = easeOut(progress);
+
+ if (style === 'fanfare') {
+ // Decorative elements that animate in
+ const decorChars = ['·', ':', '·', ':', '·'];
+ const decorWidth = Math.floor(easedProgress * (text.length / 2 + 5));
+
+ // Left decoration
+ for (let i = 0; i < Math.min(decorWidth, 5); i++) {
+ const decX = centerX - Math.floor(text.length / 2) - 6 + i;
+ fb.setPixel(decX, y, decorChars[i], depth);
+ }
+
+ // Text
+ if (easedProgress > 0.3) {
+ const textProgress = (easedProgress - 0.3) / 0.5;
+ const visibleChars = Math.floor(textProgress * text.length);
+ const textX = centerX - Math.floor(text.length / 2);
+
+ for (let i = 0; i < visibleChars; i++) {
+ fb.setPixel(textX + i, y, text[i], depth + 3);
+ }
+ }
+
+ // Right decoration
+ if (easedProgress > 0.5) {
+ for (let i = 0; i < Math.min(decorWidth, 5); i++) {
+ const decX = centerX + Math.floor(text.length / 2) + 1 + i;
+ fb.setPixel(decX, y, decorChars[4 - i], depth);
+ }
+ }
+ } else if (style === 'simple') {
+ const textX = centerX - Math.floor(text.length / 2);
+ const visibleChars = Math.floor(easedProgress * text.length);
+
+ for (let i = 0; i < visibleChars; i++) {
+ fb.setPixel(textX + i, y, text[i], depth + 2);
+ }
+ } else {
+ // legendary style with sparkles
+ const sparkles = ['★', '✦', '✧', '·'];
+ const sparkleIdx = Math.floor(frame / 4) % sparkles.length;
+
+ const fullText = `${sparkles[sparkleIdx]} ${text} ${sparkles[(sparkleIdx + 2) % sparkles.length]}`;
+ const textX = centerX - Math.floor(fullText.length / 2);
+ const visibleChars = Math.floor(easedProgress * fullText.length);
+
+ for (let i = 0; i < visibleChars; i++) {
+ fb.setPixel(textX + i, y, fullText[i], depth + 3);
+ }
+ }
+}
+
+// ============================================================================
+// XP BAR - Animated experience bar
+// ============================================================================
+
+/**
+ * Draw an XP progress bar
+ * @param fb - Framebuffer
+ * @param x - X position
+ * @param y - Y position
+ * @param options - { current, max, label }
+ * @param progress - Animation progress 0-1
+ * @param displayOptions - { width, showNumbers }
+ */
+function xpBar(fb, x, y, options, progress, displayOptions = {}) {
+ const { current = 0, max = 100, label = 'LVL' } = options;
+ const { width = 40, showNumbers = true, depth = 5 } = displayOptions;
+
+ const easedProgress = easeOut(progress);
+
+ // Draw label
+ for (let i = 0; i < label.length; i++) {
+ fb.setPixel(x + i, y, label[i], depth + 1);
+ }
+
+ // Draw bar
+ const barStart = x + label.length + 2;
+ const barWidth = width - label.length - (showNumbers ? 15 : 2);
+ const fillPercent = current / max;
+ const animatedFill = fillPercent * easedProgress;
+ const filledWidth = Math.floor(animatedFill * barWidth);
+
+ for (let i = 0; i < barWidth; i++) {
+ const char = i < filledWidth ? '█' : '░';
+ fb.setPixel(barStart + i, y, char, depth + (i < filledWidth ? 2 : 0));
+ }
+
+ // Draw numbers
+ if (showNumbers) {
+ const animatedCurrent = Math.floor(easedProgress * current);
+ const numStr = `${animatedCurrent.toLocaleString()} / ${max.toLocaleString()} XP`;
+ const numX = barStart + barWidth + 2;
+
+ for (let i = 0; i < numStr.length; i++) {
+ fb.setPixel(numX + i, y, numStr[i], depth);
+ }
+ }
+}
+
+// ============================================================================
+// LEVEL UP - Level up celebration display
+// ============================================================================
+
+/**
+ * Draw a level up celebration
+ * @param fb - Framebuffer
+ * @param options - { level, stats }
+ * @param progress - Animation progress 0-1
+ * @param frame - Current frame for effects
+ * @param displayOptions - { y }
+ */
+function levelUp(fb, options, progress, frame, displayOptions = {}) {
+ const { level = 1, stats = [] } = options;
+ const { y = 8, depth = 5 } = displayOptions;
+
+ const centerX = Math.floor(fb.width / 2);
+ const easedProgress = easeOut(progress);
+
+ const boxWidth = 25;
+ const boxX = centerX - Math.floor(boxWidth / 2);
+ let currentY = y;
+
+ // Box with glow effect
+ if (easedProgress > 0.1) {
+ const visibleWidth = Math.floor(((easedProgress - 0.1) / 0.3) * boxWidth);
+
+ // Top border
+ fb.setPixel(boxX, currentY, '╔', depth);
+ for (let i = 1; i < Math.min(visibleWidth - 1, boxWidth - 1); i++) {
+ fb.setPixel(boxX + i, currentY, '═', depth);
+ }
+ if (visibleWidth >= boxWidth) fb.setPixel(boxX + boxWidth - 1, currentY, '╗', depth);
+ currentY++;
+
+ // Level up text
+ if (easedProgress > 0.3) {
+ fb.setPixel(boxX, currentY, '║', depth);
+ const lvlText = 'LEVEL UP!';
+ const lvlX = boxX + Math.floor((boxWidth - lvlText.length) / 2);
+ for (let i = 0; i < lvlText.length; i++) {
+ fb.setPixel(lvlX + i, currentY, lvlText[i], depth + 3);
+ }
+ fb.setPixel(boxX + boxWidth - 1, currentY, '║', depth);
+ currentY++;
+
+ // Level number with sparkle
+ const sparkles = ['★', '✦', '✧', '*'];
+ const sparkleIdx = Math.floor(frame / 5) % sparkles.length;
+ const levelText = `${sparkles[sparkleIdx]} LV. ${level} ${sparkles[(sparkleIdx + 2) % sparkles.length]}`;
+ const levelX = boxX + Math.floor((boxWidth - levelText.length) / 2);
+
+ fb.setPixel(boxX, currentY, '║', depth);
+ for (let i = 0; i < levelText.length; i++) {
+ fb.setPixel(levelX + i, currentY, levelText[i], depth + 2);
+ }
+ fb.setPixel(boxX + boxWidth - 1, currentY, '║', depth);
+ currentY++;
+ }
+
+ // Divider
+ if (easedProgress > 0.5) {
+ fb.setPixel(boxX, currentY, '╠', depth);
+ for (let i = 1; i < boxWidth - 1; i++) {
+ fb.setPixel(boxX + i, currentY, '═', depth);
+ }
+ fb.setPixel(boxX + boxWidth - 1, currentY, '╣', depth);
+ currentY++;
+
+ // Stats gained
+ for (let i = 0; i < stats.length; i++) {
+ const statProgress = Math.min(1, (easedProgress - 0.5 - i * 0.1) / 0.15);
+ if (statProgress > 0) {
+ fb.setPixel(boxX, currentY, '║', depth);
+
+ const stat = stats[i];
+ const statLine = `${stat.name.padEnd(12)}${stat.gained}`;
+ const visibleStat = statLine.slice(0, Math.floor(statProgress * statLine.length));
+
+ for (let j = 0; j < visibleStat.length && j < boxWidth - 4; j++) {
+ fb.setPixel(boxX + 2 + j, currentY, visibleStat[j], depth + 1);
+ }
+
+ fb.setPixel(boxX + boxWidth - 1, currentY, '║', depth);
+ currentY++;
+ }
+ }
+ }
+
+ // Bottom border
+ if (easedProgress > 0.85) {
+ fb.setPixel(boxX, currentY, '╚', depth);
+ for (let i = 1; i < boxWidth - 1; i++) {
+ fb.setPixel(boxX + i, currentY, '═', depth);
+ }
+ fb.setPixel(boxX + boxWidth - 1, currentY, '╝', depth);
+ }
+ }
+}
+
+// ============================================================================
+// STATS PANEL - RPG-style stats display
+// ============================================================================
+
+/**
+ * Draw an RPG stats panel
+ * @param fb - Framebuffer
+ * @param x - X position
+ * @param y - Y position
+ * @param stats - Object of stat name -> value
+ * @param progress - Animation progress 0-1
+ * @param options - { style, width }
+ */
+function statsPanel(fb, x, y, stats, progress, options = {}) {
+ const { style = 'bordered', width = 30, depth = 5 } = options;
+
+ const easedProgress = easeOut(progress);
+ const statEntries = Object.entries(stats);
+ let currentY = y;
+
+ if (style === 'bordered') {
+ // Header
+ fb.setPixel(x, currentY, '┌', depth);
+ fb.setPixel(x + 1, currentY, '─', depth);
+ const headerText = ' HERO STATS ';
+ for (let i = 0; i < headerText.length; i++) {
+ fb.setPixel(x + 2 + i, currentY, headerText[i], depth + 1);
+ }
+ for (let i = 2 + headerText.length; i < width - 1; i++) {
+ fb.setPixel(x + i, currentY, '─', depth);
+ }
+ fb.setPixel(x + width - 1, currentY, '┐', depth);
+ currentY++;
+
+ // Stats
+ for (let i = 0; i < statEntries.length; i++) {
+ const [statName, statValue] = statEntries[i];
+ const statProgress = Math.min(1, (easedProgress - i * 0.1) / 0.2);
+
+ if (statProgress > 0) {
+ fb.setPixel(x, currentY, '│', depth);
+
+ const valueStr = typeof statValue === 'number' ? statValue.toLocaleString() : statValue;
+ const animatedValue = typeof statValue === 'number'
+ ? Math.floor(statProgress * statValue).toLocaleString()
+ : valueStr;
+
+ const statLine = `${statName.padEnd(width - animatedValue.length - 4)}${animatedValue}`;
+
+ for (let j = 0; j < statLine.length && j < width - 2; j++) {
+ fb.setPixel(x + 1 + j, currentY, statLine[j], depth);
+ }
+
+ fb.setPixel(x + width - 1, currentY, '│', depth);
+ currentY++;
+ }
+ }
+
+ // Footer
+ if (easedProgress > 0.8) {
+ fb.setPixel(x, currentY, '└', depth);
+ for (let i = 1; i < width - 1; i++) {
+ fb.setPixel(x + i, currentY, '─', depth);
+ }
+ fb.setPixel(x + width - 1, currentY, '┘', depth);
+ }
+ }
+}
+
+// ============================================================================
+// BOSS HEALTH - Boss battle health bar
+// ============================================================================
+
+/**
+ * Draw a boss health bar
+ * @param fb - Framebuffer
+ * @param y - Y position
+ * @param options - { name, health, maxHealth }
+ * @param progress - Animation progress 0-1
+ * @param frame - Current frame for effects
+ */
+function bossHealth(fb, y, options, progress, frame) {
+ const { name = 'BOSS', health = 0, maxHealth = 100, depth = 5 } = options;
+
+ const centerX = Math.floor(fb.width / 2);
+ const barWidth = 30;
+ const x = centerX - Math.floor(barWidth / 2);
+ const easedProgress = easeOut(progress);
+
+ // Boss name
+ const nameX = centerX - Math.floor(name.length / 2);
+ for (let i = 0; i < name.length; i++) {
+ fb.setPixel(nameX + i, y, name[i], depth + 2);
+ }
+
+ // Health bar
+ const currentHealth = Math.floor((1 - easedProgress) * health);
+ const healthPercent = currentHealth / maxHealth;
+ const filledWidth = Math.floor(healthPercent * (barWidth - 2));
+
+ fb.setPixel(x, y + 1, '[', depth);
+ for (let i = 0; i < barWidth - 2; i++) {
+ const char = i < filledWidth ? '█' : '░';
+ fb.setPixel(x + 1 + i, y + 1, char, depth + 1);
+ }
+ fb.setPixel(x + barWidth - 1, y + 1, ']', depth);
+
+ // Status text
+ const statusText = currentHealth <= 0 ? 'DEFEATED!' : `${Math.floor(healthPercent * 100)}%`;
+ const statusX = centerX - Math.floor(statusText.length / 2);
+
+ if (currentHealth <= 0) {
+ // Flash on defeat
+ const flash = Math.floor(frame / 5) % 2 === 0;
+ if (flash) {
+ for (let i = 0; i < statusText.length; i++) {
+ fb.setPixel(statusX + i, y + 2, statusText[i], depth + 3);
+ }
+ }
+ } else {
+ for (let i = 0; i < statusText.length; i++) {
+ fb.setPixel(statusX + i, y + 2, statusText[i], depth);
+ }
+ }
+}
+
+// ============================================================================
+// VICTORY FANFARE - Celebration effect
+// ============================================================================
+
+/**
+ * Victory celebration particles
+ * @param fb - Framebuffer
+ * @param frame - Current frame
+ * @param options - { intensity, style }
+ */
+function victoryFanfare(fb, frame, options = {}) {
+ const { intensity = 1.0, style = 'classic', depth = 3 } = options;
+
+ const chars = style === 'epic'
+ ? ['★', '✦', '✧', '◆', '●']
+ : style === 'subtle'
+ ? ['·', '*', '+']
+ : ['★', '*', '·', '+', '✦'];
+
+ const particleCount = Math.floor(intensity * 15);
+
+ for (let i = 0; i < particleCount; i++) {
+ const seed = i * 9.876 + frame * 0.03;
+ const x = Math.floor((Math.sin(seed * 5.43) * 0.5 + 0.5) * fb.width);
+ const yBase = (frame * 0.4 + i * 4) % (fb.height + 5);
+ const y = fb.height - Math.floor(yBase);
+
+ 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);
+ }
+ }
+}
+
+// ============================================================================
+// CREDITS ROLL - Scrolling end credits
+// ============================================================================
+
+/**
+ * Draw scrolling credits
+ * @param fb - Framebuffer
+ * @param items - Array of { type, text?, label?, value? }
+ * @param progress - Animation progress 0-1
+ * @param frame - Current frame
+ * @param options - { speed }
+ */
+function creditsRoll(fb, items, progress, frame, options = {}) {
+ const { speed = 0.5, depth = 5 } = options;
+
+ const centerX = Math.floor(fb.width / 2);
+ const easedProgress = easeInOut(progress);
+
+ // Calculate total height and scroll position
+ let totalHeight = 0;
+ for (const item of items) {
+ if (item.type === 'spacer') totalHeight += 2;
+ else totalHeight += 2;
+ }
+
+ const scrollOffset = Math.floor(easedProgress * (totalHeight + fb.height));
+ let currentY = fb.height - scrollOffset;
+
+ for (const item of items) {
+ if (currentY >= -2 && currentY < fb.height + 2) {
+ if (item.type === 'header') {
+ const text = item.text || '';
+ const textX = centerX - Math.floor(text.length / 2);
+ for (let i = 0; i < text.length && currentY >= 0 && currentY < fb.height; i++) {
+ fb.setPixel(textX + i, currentY, text[i], depth + 3);
+ }
+ } else if (item.type === 'stat') {
+ const line = `${item.label}: ${item.value}`;
+ const lineX = centerX - Math.floor(line.length / 2);
+ if (currentY >= 0 && currentY < fb.height) {
+ for (let i = 0; i < line.length; i++) {
+ fb.setPixel(lineX + i, currentY, line[i], depth + 1);
+ }
+ }
+ } else if (item.type === 'text') {
+ const text = item.text || '';
+ const textX = centerX - Math.floor(text.length / 2);
+ if (currentY >= 0 && currentY < fb.height) {
+ for (let i = 0; i < text.length; i++) {
+ fb.setPixel(textX + i, currentY, text[i], depth);
+ }
+ }
+ }
+ }
+
+ currentY += item.type === 'spacer' ? 2 : 2;
+ }
+}
+
+// ============================================================================
+// INVENTORY SLOT - Item display
+// ============================================================================
+
+/**
+ * Draw an inventory slot with item
+ * @param fb - Framebuffer
+ * @param x - X position
+ * @param y - Y position
+ * @param options - { icon, name, rarity }
+ * @param progress - Animation progress 0-1
+ */
+function inventorySlot(fb, x, y, options, progress) {
+ const { icon = '?', name = 'Item', rarity = 'common', depth = 5 } = options;
+
+ const easedProgress = easeOut(progress);
+ if (easedProgress < 0.1) return;
+
+ // Box
+ fb.setPixel(x, y, '┌', depth);
+ fb.setPixel(x + 1, y, '─', depth);
+ fb.setPixel(x + 2, y, '─', depth);
+ fb.setPixel(x + 3, y, '─', depth);
+ fb.setPixel(x + 4, y, '┐', depth);
+
+ fb.setPixel(x, y + 1, '│', depth);
+ fb.setPixel(x + 2, y + 1, icon, depth + 2);
+ fb.setPixel(x + 4, y + 1, '│', depth);
+
+ fb.setPixel(x, y + 2, '└', depth);
+ fb.setPixel(x + 1, y + 2, '─', depth);
+ fb.setPixel(x + 2, y + 2, '─', depth);
+ fb.setPixel(x + 3, y + 2, '─', depth);
+ fb.setPixel(x + 4, y + 2, '┘', depth);
+
+ // Name
+ if (easedProgress > 0.4) {
+ const nameProgress = (easedProgress - 0.4) / 0.3;
+ const visibleName = name.slice(0, Math.floor(nameProgress * name.length));
+ for (let i = 0; i < visibleName.length; i++) {
+ fb.setPixel(x + 6 + i, y + 1, visibleName[i], depth + 1);
+ }
+ }
+
+ // Rarity stars
+ if (easedProgress > 0.7) {
+ const rarityStars = rarity === 'legendary' ? '★★★★'
+ : rarity === 'epic' ? '★★★☆'
+ : rarity === 'rare' ? '★★☆☆'
+ : '★☆☆☆';
+
+ for (let i = 0; i < rarityStars.length; i++) {
+ fb.setPixel(x + 6 + i, y + 2, rarityStars[i], depth);
+ }
+ }
+}
+
+// Make available globally
+if (typeof globalThis !== 'undefined') {
+ Object.assign(globalThis, {
+ characterSprite,
+ titleScreen,
+ textBox,
+ classSelect,
+ questCard,
+ questBanner,
+ xpBar,
+ levelUp,
+ statsPanel,
+ bossHealth,
+ victoryFanfare,
+ creditsRoll,
+ inventorySlot,
+ });
+}
+})();
diff --git a/plugins/thinkback/skills/thinkback/helpers/scene_system.js b/plugins/thinkback/skills/thinkback/helpers/scene_system.js
new file mode 100644
index 0000000..aad1bc0
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/helpers/scene_system.js
@@ -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,
+ });
+}
+
+})();
diff --git a/plugins/thinkback/skills/thinkback/helpers/text_effects.js b/plugins/thinkback/skills/thinkback/helpers/text_effects.js
new file mode 100644
index 0000000..9705ac5
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/helpers/text_effects.js
@@ -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,
+ });
+}
+})();
diff --git a/plugins/thinkback/skills/thinkback/helpers/transitions.js b/plugins/thinkback/skills/thinkback/helpers/transitions.js
new file mode 100644
index 0000000..0fe03de
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/helpers/transitions.js
@@ -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
+ });
+}
+})();
diff --git a/plugins/thinkback/skills/thinkback/high_token_version.md b/plugins/thinkback/skills/thinkback/high_token_version.md
new file mode 100644
index 0000000..2c5484d
--- /dev/null
+++ b/plugins/thinkback/skills/thinkback/high_token_version.md
@@ -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 `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+