Browse Source

Add thinkback plugin for Year in Review animation

Adds the thinkback plugin - a personalized Year in Review ASCII animation
generator for Claude Code users.

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Thariq Shihipar 8 months ago
parent
commit
7985b28c03
34 changed files with 12853 additions and 0 deletions
  1. 11 0
      .claude-plugin/marketplace.json
  2. 8 0
      plugins/thinkback/.claude-plugin/plugin.json
  3. 35 0
      plugins/thinkback/README.md
  4. 3 0
      plugins/thinkback/package.json
  5. 159 0
      plugins/thinkback/skills/thinkback/SKILL.md
  6. 435 0
      plugins/thinkback/skills/thinkback/ascii_anim.js
  7. 996 0
      plugins/thinkback/skills/thinkback/helpers/awards_effects.js
  8. 324 0
      plugins/thinkback/skills/thinkback/helpers/backgrounds.js
  9. 313 0
      plugins/thinkback/skills/thinkback/helpers/borders.js
  10. 66 0
      plugins/thinkback/skills/thinkback/helpers/index.js
  11. 970 0
      plugins/thinkback/skills/thinkback/helpers/news_effects.js
  12. 349 0
      plugins/thinkback/skills/thinkback/helpers/particles.js
  13. 1181 0
      plugins/thinkback/skills/thinkback/helpers/rpg_effects.js
  14. 415 0
      plugins/thinkback/skills/thinkback/helpers/scene_system.js
  15. 503 0
      plugins/thinkback/skills/thinkback/helpers/text_effects.js
  16. 449 0
      plugins/thinkback/skills/thinkback/helpers/transitions.js
  17. 437 0
      plugins/thinkback/skills/thinkback/high_token_version.md
  18. 129 0
      plugins/thinkback/skills/thinkback/low_token_version.md
  19. 174 0
      plugins/thinkback/skills/thinkback/player.js
  20. 1207 0
      plugins/thinkback/skills/thinkback/scripts/get_all_stats.js
  21. 390 0
      plugins/thinkback/skills/thinkback/scripts/helpers_demo.js
  22. 145 0
      plugins/thinkback/skills/thinkback/scripts/test_intro.js
  23. 142 0
      plugins/thinkback/skills/thinkback/scripts/validate.js
  24. 399 0
      plugins/thinkback/skills/thinkback/templates/awards-show-template.js
  25. 300 0
      plugins/thinkback/skills/thinkback/templates/cozy-template.js
  26. 412 0
      plugins/thinkback/skills/thinkback/templates/morning-news-template.js
  27. 443 0
      plugins/thinkback/skills/thinkback/templates/rpg-quest-template.js
  28. 440 0
      plugins/thinkback/skills/thinkback/vibes/awards-show-vibe.md
  29. 92 0
      plugins/thinkback/skills/thinkback/vibes/cozy-vibe.md
  30. 443 0
      plugins/thinkback/skills/thinkback/vibes/morning-news-vibe.md
  31. 5 0
      plugins/thinkback/skills/thinkback/vibes/other-vibe.md
  32. 603 0
      plugins/thinkback/skills/thinkback/vibes/rpg-quest-vibe.md
  33. 502 0
      plugins/thinkback/skills/thinkback/year_in_review.html
  34. 373 0
      plugins/thinkback/skills/thinkback/year_in_review_template.js

+ 11 - 0
.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.",

+ 8 - 0
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"
+  }
+}

+ 35 - 0
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)

+ 3 - 0
plugins/thinkback/package.json

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

+ 159 - 0
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

+ 435 - 0
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
+};

+ 996 - 0
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,
+  });
+}
+})();

+ 324 - 0
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
+  });
+}
+})();

+ 313 - 0
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
+  });
+}
+})();

+ 66 - 0
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;

+ 970 - 0
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,
+  });
+}
+})();

+ 349 - 0
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
+  });
+}
+})();

+ 1181 - 0
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,
+  });
+}
+})();

+ 415 - 0
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,
+  });
+}
+
+})();

+ 503 - 0
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,
+  });
+}
+})();

+ 449 - 0
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
+  });
+}
+})();

+ 437 - 0
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 `<script>` tag in the browser, not as a module. All helpers are set on `globalThis` by the helper scripts that load before `year_in_review.js`.
+
+**CRITICAL: Destructure helpers BEFORE using SceneManager.**
+
+The `SceneManager` class must be destructured from `globalThis` before you can use `new SceneManager(...)`. Place the destructuring block at the very top of your file, before scene definitions:
+
+```javascript
+// =============================================================================
+// HELPER DESTRUCTURING (must come FIRST, before SceneManager usage)
+// =============================================================================
+
+const {
+  // Scene system (REQUIRED - must be destructured before use)
+  SceneManager, staggeredReveal, easeInOut, easeOut, animateCounter,
+  // ... other helpers
+} = globalThis;
+
+// NOW you can use SceneManager
+const SCENE_DEFINITIONS = [...];
+const sceneManager = new SceneManager(SCENE_DEFINITIONS);  // This works!
+```
+
+Access helpers by destructuring from `globalThis` at the top of your file:
+
+```javascript
+const {
+  // Transitions
+  wipeLeft, wipeRight, wipeDown, wipeUp,
+  circleReveal, circleClose, irisIn, irisOut,
+  blindsH, blindsV, checkerboard, diagonalWipe,
+  dissolve, pixelate, fade,
+  supernova, spiral, shatter, tornado,  // Creative transitions
+
+  // Backgrounds
+  stars, starfield, rain, snow, fog, aurora,
+  waves, gradient, ripples, fireflies, clouds,
+
+  // Particles
+  confetti, sparkles, burst, bubbles, hearts,
+  musicNotes, leaves, embers, dust, floatingParticles,
+  orbit, shootingStars, glitter,
+
+  // Text effects
+  typewriter, drawTypewriter, drawTypewriterCentered,
+  drawWaveText, drawGlitchText, slideIn, slideOut,
+  drawZoomText, drawFadeInText, drawScatterText,
+
+  // Claude branding & intro
+  CLAUDE_MASCOT, CLAUDE_MASCOT_WIDTH, drawClaudeMascot,
+  CLAUDE_CODE_LOGO, CLAUDE_CODE_LOGO_WIDTH, CLAUDE_CODE_LOGO_HEIGHT,
+  CLAUDE_ORANGE, drawClaudeCodeLogo, drawThinkbackIntro,
+
+  // News broadcast effects (for morning news vibe)
+  lowerThird, tickerTape, breakingBanner, liveIndicator,
+  segmentTitle, statCounter, forecastBar, splitWipe,
+  pushTransition, headlineCrawl, countdownReveal,
+
+  // Awards show effects (for awards show vibe)
+  trophyDisplay, awardBadge, envelopeReveal, categoryTitle,
+  acceptanceSpeech, nomineeCard, winnerAnnouncement, applauseMeter,
+  standingOvation, redCarpetBorder, spotlightText, spotlightReveal,
+  curtainReveal, awardsStatue,
+
+  // RPG quest effects (for rpg quest vibe)
+  characterSprite, titleScreen, textBox, classSelect,
+  questCard, questBanner, xpBar, levelUp,
+  statsPanel, bossHealth, victoryFanfare, creditsRoll, inventorySlot,
+} = globalThis;
+```
+
+### Transitions
+
+Transitions mask/reveal content. Apply after drawing your scene content.
+
+```javascript
+// Circular reveal from center (0-1 progress)
+circleReveal(fb, progress);
+
+// Wipe from left to right
+wipeRight(fb, progress, '█');
+
+// Diagonal wipe from top-left corner
+diagonalWipe(fb, progress, 'tl');
+
+// Venetian blinds effect (horizontal)
+blindsH(fb, progress, 8);
+
+// Random dissolve
+dissolve(fb, progress, seed);
+
+// Iris in/out (classic film transition)
+irisIn(fb, progress);
+irisOut(fb, progress);
+
+// Creative transitions
+supernova(fb, progress);   // Explosive burst from center
+spiral(fb, progress);      // Spinning vortex clear
+shatter(fb, progress);     // Breaking glass effect
+tornado(fb, progress);     // Swirling funnel clear
+```
+
+### Backgrounds
+
+Draw backgrounds first, before scene content.
+
+```javascript
+// Twinkling stars (cozy, slow twinkle)
+stars(fb, frame, { density: 0.006, twinkle: true });
+
+// 3D starfield zoom effect
+starfield(fb, frame, { speed: 1, numStars: 50 });
+
+// Fireflies (warm, cozy)
+fireflies(fb, frame, { count: 8 });
+
+// Snow falling
+snow(fb, frame, { density: 0.01 });
+
+// Aurora / northern lights
+aurora(fb, frame, { intensity: 0.5 });
+
+// Gradient background
+gradient(fb, { direction: 'vertical', invert: false });
+
+// Concentric ripples from center
+ripples(fb, frame, { speed: 1, char: '·' });
+```
+
+### Particles
+
+Particles add atmosphere and celebration.
+
+```javascript
+// Gentle floating particles
+floatingParticles(fb, frame, { count: 12, char: '◇' });
+
+// Confetti celebration
+confetti(fb, frame, { count: 20 });
+
+// Sparkles twinkling
+sparkles(fb, frame, { density: 0.005 });
+
+// Rising embers
+embers(fb, frame, { count: 10 });
+
+// Floating hearts
+hearts(fb, frame, { count: 6 });
+
+// Dust motes in light
+dust(fb, frame, { density: 0.003 });
+
+// Orbiting particles around center
+orbit(fb, frame, { cx: 40, cy: 12, radius: 5, count: 4 });
+```
+
+### Text Effects
+
+Text animations for revealing and emphasizing text.
+
+```javascript
+// Typewriter effect (returns partial text)
+const visibleText = typewriter('Hello World', progress);
+
+// Draw with typewriter + cursor
+drawTypewriterCentered(fb, y, 'Hello World', progress);
+
+// Slide text in from edge
+slideIn(fb, y, 'Welcome', progress, { from: 'left' });
+
+// Wave effect (each character bobs)
+drawWaveText(fb, y, 'Wavy Text', frame, { amplitude: 1 });
+
+// Zoom text from center
+drawZoomText(fb, y, 'Zoom!', progress);
+
+// Fade in character by character
+drawFadeInText(fb, y, 'Fading in', progress);
+
+// Scatter then reassemble
+drawScatterText(fb, 'Scatter', progress);
+```
+
+### Claude Mascot (Clawd)
+
+The Claude mascot "Clawd" is a small ASCII art logo that can be used in opener/closer scenes.
+
+```javascript
+// Draw Claude mascot centered at position
+// CLAUDE_MASCOT is an array of 3 lines
+// CLAUDE_MASCOT_WIDTH is 10 characters
+drawClaudeMascot(fb, fb.width / 2, 5, CLAUDE_ORANGE);
+
+// The mascot looks like:
+//  ▐▛███▜▌
+// ▝▜█████▛▘
+//   ▘▘ ▝▝
+```
+
+### Claude Code Logo
+
+Large ASCII art logo for the Claude Code branding.
+
+```javascript
+// Draw the full Claude Code logo (12 lines tall, 50 chars wide)
+drawClaudeCodeLogo(fb, fb.width / 2, 5, CLAUDE_ORANGE);
+
+// The logo displays:
+//    ██████╗██╗      █████╗ ██╗   ██╗██████╗ ███████╗
+//   ██╔════╝██║     ██╔══██╗██║   ██║██╔══██╗██╔════╝
+//   ██║     ██║     ███████║██║   ██║██║  ██║█████╗
+//   ██║     ██║     ██╔══██║██║   ██║██║  ██║██╔══╝
+//   ╚██████╗███████╗██║  ██║╚██████╔╝██████╔╝███████╗
+//    ╚═════╝╚══════╝╚═╝  ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
+//                  ██████╗ ██████╗ ██████╗ ███████╗
+//                 ██╔════╝██╔═══██╗██╔══██╗██╔════╝
+//                 ██║     ██║   ██║██║  ██║█████╗
+//                 ██║     ██║   ██║██║  ██║██╔══╝
+//                 ╚██████╗╚██████╔╝██████╔╝███████╗
+//                  ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
+```
+
+### Thinkback Intro Scene (REQUIRED)
+
+**Every animation MUST use this as the first scene.** It provides a consistent, branded intro experience.
+
+```javascript
+// In your intro scene renderer:
+case 'INTRO': {
+  // Add a starfield background
+  stars(fb, frame, { density: 0.008, twinkle: true });
+
+  // Draw the complete intro scene with Clawd, logo, and standard text
+  drawThinkbackIntro(fb, frame, p);
+
+  // Handle transition out
+  if (scene.phase === 'TRANSITION_OUT') {
+    dissolve(fb, scene.transitionProgress, frame);
+  }
+  break;
+}
+```
+
+The intro displays:
+- Clawd mascot fading in
+- Claude Code logo with dissolve effect
+- "Think Back on..." with typewriter effect
+- "your year with Claude Code" subtitle
+- Year at the bottom
+
+### Example Scene with Helpers
+
+```javascript
+case 'OPENING': {
+  // Background: twinkling stars
+  stars(fb, frame, { density: 0.008, twinkle: true });
+
+  // Particles: gentle floating diamonds
+  floatingParticles(fb, frame, { count: 15, char: '◇' });
+
+  // Text: typewriter reveal
+  const title = 'Your Year in Review';
+  if (p < 0.6) {
+    drawTypewriterCentered(fb, 10, title, p / 0.6);
+  } else {
+    fb.drawCenteredText(10, title);
+  }
+
+  // Transition: iris out at end of scene
+  if (p > 0.85) {
+    irisOut(fb, (p - 0.85) / 0.15);
+  }
+  break;
+}
+```
+
+## Critical Export Format
+
+**IMPORTANT: The generated `year_in_review.js` MUST export in this exact format for the HTML to work:**
+
+```javascript
+// =============================================================================
+// EXPORTS - MUST match what year_in_review.html expects
+// =============================================================================
+
+globalThis.YearInReviewScenes = {
+  TOTAL_FRAMES: sceneManager.getTotalFrames(),
+  mainAnimation: render,  // Your main render function
+  getSceneName: (frame) => {
+    const { sceneId } = sceneManager.getScene(frame);
+    return sceneId || 'Complete';
+  },
+  sceneManager,
+};
+```
+
+**DO NOT use these incorrect patterns:**
+```javascript
+// WRONG - will cause "YearInReviewScenes not loaded" error
+globalThis.render = render;
+globalThis.TOTAL_FRAMES = sceneManager.getTotalFrames();
+
+// WRONG - using ES module exports (not supported in browser script tag)
+export { render, TOTAL_FRAMES };
+```
+
+The `year_in_review.html` file specifically checks for `globalThis.YearInReviewScenes` and calls `mainAnimation` from it.

+ 129 - 0
plugins/thinkback/skills/thinkback/low_token_version.md

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

+ 174 - 0
plugins/thinkback/skills/thinkback/player.js

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

+ 1207 - 0
plugins/thinkback/skills/thinkback/scripts/get_all_stats.js

@@ -0,0 +1,1207 @@
+#!/usr/bin/env node
+/* eslint-disable custom-rules/no-console-new-code, custom-rules/no-process-exit, custom-rules/no-top-level-side-effects */
+/**
+ * Unified statistics extraction for Thinkback
+ *
+ * Consolidates git commits, session data, and time patterns into a single
+ * narrative-friendly JSON output. Designed to work on any user's machine.
+ *
+ * Usage:
+ *   node get_all_stats.js              # Full output as JSON
+ *   node get_all_stats.js --git-only   # Just git stats
+ *   node get_all_stats.js --recent-only # Just session stats (recent ~30 days)
+ *   node get_all_stats.js --markdown   # Also write detailed markdown report
+ *   node get_all_stats.js --markdown-path=/path/to/output.md
+ *   node get_all_stats.js --help
+ */
+
+import { execFile, execFileSync } from 'child_process';
+import { promisify } from 'util';
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+
+const execFileAsync = promisify(execFile);
+
+// =============================================================================
+// ARGUMENT PARSING
+// =============================================================================
+
+function parseArgs(argv) {
+  const args = {
+    mode: 'all',
+    help: false,
+    markdown: false,
+    markdownPath: null,
+  };
+
+  for (let i = 2; i < argv.length; i++) {
+    const arg = argv[i];
+    if (arg.startsWith('--markdown-path=')) {
+      args.markdown = true;
+      args.markdownPath = arg.slice('--markdown-path='.length);
+    } else {
+      switch (arg) {
+        case '--git-only':
+          args.mode = 'git';
+          break;
+        case '--recent-only':
+          args.mode = 'recent';
+          break;
+        case '--markdown':
+          args.markdown = true;
+          break;
+        case '--help':
+        case '-h':
+          args.help = true;
+          break;
+      }
+    }
+  }
+
+  return args;
+}
+
+// =============================================================================
+// GIT USER IDENTITY
+// =============================================================================
+
+function getGitUser() {
+  try {
+    const name = execFileSync('git', ['config', 'user.name'], { encoding: 'utf-8' }).trim();
+    const email = execFileSync('git', ['config', 'user.email'], { encoding: 'utf-8' }).trim();
+    return { name, email };
+  } catch {
+    return { name: null, email: null };
+  }
+}
+
+// =============================================================================
+// REPO DISCOVERY (from ~/.claude/projects/ folder names)
+// =============================================================================
+
+function pathExists(p) {
+  try {
+    fs.accessSync(p);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function folderNameToPath(folderName) {
+  if (!folderName.startsWith('-')) {
+    return folderName.replace(/-/g, '/');
+  }
+
+  const parts = folderName.slice(1).split('-');
+  let currentPath = '';
+  let i = 0;
+
+  while (i < parts.length) {
+    let found = false;
+    for (let len = parts.length - i; len >= 1; len--) {
+      const segment = parts.slice(i, i + len).join('-');
+      const testPath = currentPath + '/' + segment;
+      if (pathExists(testPath)) {
+        currentPath = testPath;
+        i += len;
+        found = true;
+        break;
+      }
+    }
+    if (!found) {
+      currentPath += '/' + parts[i];
+      i++;
+    }
+  }
+
+  return currentPath;
+}
+
+function findGitRoot(dirPath) {
+  // Resolve to absolute path to avoid infinite loops with relative paths
+  let current = path.resolve(dirPath);
+  const root = path.parse(current).root;
+
+  while (current !== root) {
+    const gitPath = path.join(current, '.git');
+    try {
+      if (fs.existsSync(gitPath)) {
+        return current;
+      }
+    } catch {
+      // Permission denied
+    }
+    const parent = path.dirname(current);
+    // Safety check: stop if we're not making progress
+    if (parent === current) {
+      break;
+    }
+    current = parent;
+  }
+
+  return null;
+}
+
+function discoverReposFromProjects() {
+  const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
+
+  if (!fs.existsSync(claudeProjectsDir)) {
+    return [];
+  }
+
+  const folderNames = fs.readdirSync(claudeProjectsDir);
+  const gitRepos = new Set();
+
+  for (const folderName of folderNames) {
+    const decodedPath = folderNameToPath(folderName);
+    if (!pathExists(decodedPath)) continue;
+
+    const gitRoot = findGitRoot(decodedPath);
+    if (gitRoot) {
+      gitRepos.add(gitRoot);
+    }
+  }
+
+  return [...gitRepos].sort();
+}
+
+// Fallback: scan ~/code for git repos
+function findGitReposInPath(searchPath) {
+  const repos = [];
+
+  function walk(dir, depth = 0) {
+    if (depth > 5) return;
+
+    try {
+      const entries = fs.readdirSync(dir, { withFileTypes: true });
+
+      for (const entry of entries) {
+        if (!entry.isDirectory()) continue;
+        if (entry.name.startsWith('.') && entry.name !== '.git') continue;
+
+        const fullPath = path.join(dir, entry.name);
+
+        if (entry.name === '.git') {
+          repos.push(dir);
+          return;
+        }
+
+        if (['node_modules', 'vendor', 'dist', 'build', '.next'].includes(entry.name)) {
+          continue;
+        }
+
+        walk(fullPath, depth + 1);
+      }
+    } catch {
+      // Permission denied
+    }
+  }
+
+  walk(searchPath);
+  return repos;
+}
+
+// =============================================================================
+// GIT COMMIT EXTRACTION
+// =============================================================================
+
+async function getClaudeCommitsFromRepo(repoPath, author) {
+  const gitArgs = [
+    '-C', repoPath,
+    'log', '--all',
+    '--grep=Co-Authored-By: Claude',
+    '--format=%H|%ai|%aI|%s',
+  ];
+
+  if (author) {
+    gitArgs.push(`--author=${author}`);
+  }
+
+  try {
+    const { stdout } = await execFileAsync('git', gitArgs, { maxBuffer: 10 * 1024 * 1024 });
+    const lines = stdout.trim().split('\n').filter(line => line);
+
+    return lines.map(line => {
+      const [hash, dateStr, isoDate, ...subjectParts] = line.split('|');
+      const subject = subjectParts.join('|');
+      const date = new Date(isoDate);
+      return {
+        hash,
+        date,
+        dateStr,
+        subject,
+        repo: path.basename(repoPath),
+        repoPath,
+      };
+    });
+  } catch {
+    return [];
+  }
+}
+
+function aggregateGitStats(commits) {
+  if (commits.length === 0) {
+    return {
+      totalCommits: 0,
+      firstCommit: null,
+      lastCommit: null,
+      repos: {},
+      repoCount: 0,
+      byMonth: {},
+      byDayOfWeek: { Sun: 0, Mon: 0, Tue: 0, Wed: 0, Thu: 0, Fri: 0, Sat: 0 },
+      byHour: {},
+      peakHour: null,
+      peakDay: null,
+      topRepos: [],
+    };
+  }
+
+  // Dedupe commits by hash (worktrees share commits)
+  const seenHashes = new Set();
+  const uniqueCommits = [];
+  for (const commit of commits) {
+    if (!seenHashes.has(commit.hash)) {
+      seenHashes.add(commit.hash);
+      uniqueCommits.push(commit);
+    }
+  }
+
+  uniqueCommits.sort((a, b) => a.date - b.date);
+
+  const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+  const repos = {};
+  const byMonth = {};
+  const byDayOfWeek = { Sun: 0, Mon: 0, Tue: 0, Wed: 0, Thu: 0, Fri: 0, Sat: 0 };
+  const byHour = {};
+
+  for (const commit of uniqueCommits) {
+    repos[commit.repo] = (repos[commit.repo] || 0) + 1;
+
+    const month = commit.date.toISOString().slice(0, 7);
+    byMonth[month] = (byMonth[month] || 0) + 1;
+
+    const day = dayNames[commit.date.getDay()];
+    byDayOfWeek[day]++;
+
+    const hour = commit.date.getHours();
+    byHour[hour] = (byHour[hour] || 0) + 1;
+  }
+
+  // Sort repos by count
+  const sortedRepos = Object.fromEntries(
+    Object.entries(repos).sort((a, b) => b[1] - a[1])
+  );
+
+  // Find peak hour
+  let peakHour = null;
+  let maxHourCount = 0;
+  for (const [hour, count] of Object.entries(byHour)) {
+    if (count > maxHourCount) {
+      maxHourCount = count;
+      peakHour = { hour: parseInt(hour), count, label: formatHour(parseInt(hour)) };
+    }
+  }
+
+  // Find peak day
+  let peakDay = null;
+  let maxDayCount = 0;
+  for (const [day, count] of Object.entries(byDayOfWeek)) {
+    if (count > maxDayCount) {
+      maxDayCount = count;
+      peakDay = { day, count };
+    }
+  }
+
+  // Top 5 repos
+  const topRepos = Object.entries(sortedRepos)
+    .slice(0, 5)
+    .map(([name, count]) => ({ name, count }));
+
+  return {
+    totalCommits: uniqueCommits.length,
+    firstCommit: {
+      date: uniqueCommits[0].dateStr,
+      repo: uniqueCommits[0].repo,
+      subject: uniqueCommits[0].subject,
+    },
+    lastCommit: {
+      date: uniqueCommits[uniqueCommits.length - 1].dateStr,
+      repo: uniqueCommits[uniqueCommits.length - 1].repo,
+      subject: uniqueCommits[uniqueCommits.length - 1].subject,
+    },
+    repos: sortedRepos,
+    repoCount: Object.keys(repos).length,
+    byMonth,
+    byDayOfWeek,
+    byHour,
+    peakHour,
+    peakDay,
+    topRepos,
+  };
+}
+
+// =============================================================================
+// SESSION FILE PROCESSING (No tool uses - per user request)
+// =============================================================================
+
+function findSessionFiles() {
+  const claudeDir = path.join(os.homedir(), '.claude', 'projects');
+  const sessionFiles = [];
+
+  if (!fs.existsSync(claudeDir)) {
+    return sessionFiles;
+  }
+
+  const projects = fs.readdirSync(claudeDir);
+
+  for (const project of projects) {
+    const projectDir = path.join(claudeDir, project);
+    const stat = fs.statSync(projectDir);
+    if (!stat.isDirectory()) continue;
+
+    const files = fs.readdirSync(projectDir);
+    for (const file of files) {
+      if (file.endsWith('.jsonl')) {
+        sessionFiles.push({
+          project,
+          path: path.join(projectDir, file),
+          filename: file,
+        });
+      }
+    }
+  }
+
+  return sessionFiles;
+}
+
+function parseSessionFile(filePath) {
+  const stats = {
+    userMessages: 0,
+    assistantMessages: 0,
+    timestamps: [],
+    firstTimestamp: null,
+    lastTimestamp: null,
+  };
+
+  let content;
+  try {
+    content = fs.readFileSync(filePath, 'utf-8');
+  } catch {
+    return stats;
+  }
+
+  const lines = content.split('\n').filter(line => line.trim());
+
+  for (const line of lines) {
+    let msg;
+    try {
+      msg = JSON.parse(line);
+    } catch {
+      continue;
+    }
+
+    if (msg.timestamp) {
+      const msgDate = new Date(msg.timestamp);
+      stats.timestamps.push(msgDate);
+
+      if (!stats.firstTimestamp || msgDate < stats.firstTimestamp) {
+        stats.firstTimestamp = msgDate;
+      }
+      if (!stats.lastTimestamp || msgDate > stats.lastTimestamp) {
+        stats.lastTimestamp = msgDate;
+      }
+    }
+
+    if (msg.type === 'user') {
+      stats.userMessages++;
+    } else if (msg.type === 'assistant') {
+      stats.assistantMessages++;
+    }
+  }
+
+  return stats;
+}
+
+// =============================================================================
+// DETAILED SESSION MESSAGE EXTRACTION (for markdown report)
+// =============================================================================
+
+function extractRecentMessages(filePath, limit = 10) {
+  const messages = [];
+
+  let content;
+  try {
+    content = fs.readFileSync(filePath, 'utf-8');
+  } catch {
+    return messages;
+  }
+
+  const lines = content.split('\n').filter(line => line.trim());
+
+  for (const line of lines) {
+    let msg;
+    try {
+      msg = JSON.parse(line);
+    } catch {
+      continue;
+    }
+
+    if (msg.type === 'user' && msg.message?.content) {
+      // Extract text content from user message
+      let text = '';
+      if (typeof msg.message.content === 'string') {
+        text = msg.message.content;
+      } else if (Array.isArray(msg.message.content)) {
+        text = msg.message.content
+          .filter(c => c.type === 'text')
+          .map(c => c.text)
+          .join(' ');
+      }
+
+      if (text.trim()) {
+        messages.push({
+          type: 'user',
+          timestamp: msg.timestamp ? new Date(msg.timestamp) : null,
+          text: text.slice(0, 500), // Truncate long messages
+        });
+      }
+    }
+  }
+
+  // Return only the most recent messages
+  return messages.slice(-limit);
+}
+
+function getRecentMessagesPerProject() {
+  const claudeDir = path.join(os.homedir(), '.claude', 'projects');
+  const projectMessages = {};
+
+  if (!fs.existsSync(claudeDir)) {
+    return projectMessages;
+  }
+
+  const projects = fs.readdirSync(claudeDir);
+
+  for (const project of projects) {
+    const projectDir = path.join(claudeDir, project);
+    try {
+      const stat = fs.statSync(projectDir);
+      if (!stat.isDirectory()) continue;
+    } catch {
+      continue;
+    }
+
+    const files = fs.readdirSync(projectDir);
+    const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
+
+    // Get the most recent session file
+    let mostRecentFile = null;
+    let mostRecentTime = 0;
+
+    for (const file of jsonlFiles) {
+      const filePath = path.join(projectDir, file);
+      try {
+        const fileStat = fs.statSync(filePath);
+        if (fileStat.mtimeMs > mostRecentTime) {
+          mostRecentTime = fileStat.mtimeMs;
+          mostRecentFile = filePath;
+        }
+      } catch {
+        continue;
+      }
+    }
+
+    if (mostRecentFile) {
+      const decodedProject = decodeProjectName(project);
+      const messages = extractRecentMessages(mostRecentFile, 5);
+      if (messages.length > 0) {
+        projectMessages[decodedProject] = {
+          encodedName: project,
+          messages,
+          lastActive: new Date(mostRecentTime),
+        };
+      }
+    }
+  }
+
+  return projectMessages;
+}
+
+// =============================================================================
+// TIME PATTERN HELPERS
+// =============================================================================
+
+function formatHour(hour) {
+  if (hour === 0) return '12am';
+  if (hour === 12) return '12pm';
+  if (hour < 12) return `${hour}am`;
+  return `${hour - 12}pm`;
+}
+
+function formatDate(date) {
+  return date.toISOString().split('T')[0];
+}
+
+function getDayName(dayIndex) {
+  const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+  return days[dayIndex];
+}
+
+function getISOWeek(date) {
+  const d = new Date(date);
+  d.setHours(0, 0, 0, 0);
+  d.setDate(d.getDate() + 4 - (d.getDay() || 7));
+  const yearStart = new Date(d.getFullYear(), 0, 1);
+  const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
+  return `${d.getFullYear()}-W${String(weekNo).padStart(2, '0')}`;
+}
+
+function getWeekDateRange(isoWeek) {
+  const [year, week] = isoWeek.split('-W').map(Number);
+  const jan1 = new Date(year, 0, 1);
+  const days = (week - 1) * 7;
+  const weekStart = new Date(jan1.getTime() + days * 86400000);
+  const dayOfWeek = weekStart.getDay();
+  const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
+  weekStart.setDate(weekStart.getDate() + diff);
+  const weekEnd = new Date(weekStart.getTime() + 6 * 86400000);
+
+  const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+  return `${months[weekStart.getMonth()]} ${weekStart.getDate()}-${weekEnd.getDate()}, ${year}`;
+}
+
+// =============================================================================
+// SESSION STATS AGGREGATION
+// =============================================================================
+
+function aggregateSessionStats(sessionFiles) {
+  const sessions = [];
+  const projectStats = {};
+  const hourCounts = Array(24).fill(0);
+  const weekdayCounts = Array(7).fill(0);
+  const daySessions = {};
+  const weekSessions = {};
+  let totalUserMessages = 0;
+  let totalAssistantMessages = 0;
+  let firstTimestamp = null;
+  let lastTimestamp = null;
+
+  for (const sessionFile of sessionFiles) {
+    const stats = parseSessionFile(sessionFile.path);
+
+    if (stats.timestamps.length === 0) continue;
+
+    const session = {
+      project: sessionFile.project,
+      filename: sessionFile.filename,
+      messages: stats.userMessages + stats.assistantMessages,
+      userMessages: stats.userMessages,
+      assistantMessages: stats.assistantMessages,
+      firstTimestamp: stats.firstTimestamp,
+      lastTimestamp: stats.lastTimestamp,
+      duration: stats.lastTimestamp - stats.firstTimestamp,
+    };
+
+    sessions.push(session);
+    totalUserMessages += stats.userMessages;
+    totalAssistantMessages += stats.assistantMessages;
+
+    // Track per-project stats
+    if (!projectStats[sessionFile.project]) {
+      projectStats[sessionFile.project] = { sessions: 0, messages: 0 };
+    }
+    projectStats[sessionFile.project].sessions++;
+    projectStats[sessionFile.project].messages += session.messages;
+
+    // Track timestamps
+    if (!firstTimestamp || stats.firstTimestamp < firstTimestamp) {
+      firstTimestamp = stats.firstTimestamp;
+    }
+    if (!lastTimestamp || stats.lastTimestamp > lastTimestamp) {
+      lastTimestamp = stats.lastTimestamp;
+    }
+
+    // Track hourly/weekday patterns
+    const hour = stats.firstTimestamp.getHours();
+    const weekday = stats.firstTimestamp.getDay();
+    hourCounts[hour]++;
+    weekdayCounts[weekday]++;
+
+    // Track daily sessions
+    const dateStr = formatDate(stats.firstTimestamp);
+    daySessions[dateStr] = (daySessions[dateStr] || 0) + 1;
+
+    // Track weekly sessions
+    const weekStr = getISOWeek(stats.firstTimestamp);
+    if (!weekSessions[weekStr]) {
+      weekSessions[weekStr] = { sessions: 0, messages: 0 };
+    }
+    weekSessions[weekStr].sessions++;
+    weekSessions[weekStr].messages += session.messages;
+  }
+
+  // Sort projects by session count and decode names
+  const topProjects = Object.entries(projectStats)
+    .sort((a, b) => b[1].sessions - a[1].sessions)
+    .slice(0, 5)
+    .map(([name]) => {
+      // Decode the folder name to get the actual path, then extract the last part
+      const decoded = folderNameToPath(name);
+      return path.basename(decoded);
+    });
+
+  return {
+    sessions,
+    projectStats,
+    hourCounts,
+    weekdayCounts,
+    daySessions,
+    weekSessions,
+    totalUserMessages,
+    totalAssistantMessages,
+    firstTimestamp,
+    lastTimestamp,
+    topProjects,
+  };
+}
+
+// =============================================================================
+// PATTERN CALCULATIONS
+// =============================================================================
+
+function calculatePatterns(data) {
+  const totalSessions = data.sessions.length;
+
+  // Hourly patterns
+  let peakHour = { hour: 0, label: '12am', sessions: 0 };
+  let quietestHour = { hour: 0, label: '12am', sessions: Infinity };
+
+  for (let hour = 0; hour < 24; hour++) {
+    const count = data.hourCounts[hour];
+    if (count > peakHour.sessions) {
+      peakHour = { hour, label: formatHour(hour), sessions: count };
+    }
+    if (count < quietestHour.sessions) {
+      quietestHour = { hour, label: formatHour(hour), sessions: count };
+    }
+  }
+
+  // Weekly patterns
+  let busiestDay = { name: 'Monday', sessions: 0 };
+  let quietestDay = { name: 'Monday', sessions: Infinity };
+
+  for (let day = 0; day < 7; day++) {
+    const count = data.weekdayCounts[day];
+    const name = getDayName(day);
+    if (count > busiestDay.sessions) {
+      busiestDay = { name, sessions: count };
+    }
+    if (count < quietestDay.sessions) {
+      quietestDay = { name, sessions: count };
+    }
+  }
+
+  // Night owl (10pm - 4am)
+  const nightHours = [22, 23, 0, 1, 2, 3];
+  const nightSessions = nightHours.reduce((sum, h) => sum + data.hourCounts[h], 0);
+
+  let latestSession = null;
+  let latestHour = -1;
+  for (const session of data.sessions) {
+    const hour = session.firstTimestamp.getHours();
+    const nightHour = hour < 6 ? hour + 24 : hour;
+    if (nightHour >= 22 && nightHour > latestHour) {
+      latestHour = nightHour;
+      latestSession = session;
+    }
+  }
+
+  // Early bird (5am - 8am)
+  const earlyHours = [5, 6, 7, 8];
+  const earlySessions = earlyHours.reduce((sum, h) => sum + data.hourCounts[h], 0);
+
+  let earliestSession = null;
+  let earliestHour = 24;
+  for (const session of data.sessions) {
+    const hour = session.firstTimestamp.getHours();
+    if (hour >= 5 && hour <= 8 && hour < earliestHour) {
+      earliestHour = hour;
+      earliestSession = session;
+    }
+  }
+
+  // Weekend warrior
+  const weekendSessions = data.weekdayCounts[6] + data.weekdayCounts[0];
+
+  return {
+    hourly: { peakHour, quietestHour },
+    weekly: { busiestDay, quietestDay },
+    nightOwl: {
+      sessions: nightSessions,
+      percentage: totalSessions > 0 ? Math.round((nightSessions / totalSessions) * 1000) / 10 : 0,
+      latestTime: latestSession ? formatHour(latestSession.firstTimestamp.getHours()) : null,
+    },
+    earlyBird: {
+      sessions: earlySessions,
+      percentage: totalSessions > 0 ? Math.round((earlySessions / totalSessions) * 1000) / 10 : 0,
+      earliestTime: earliestSession ? formatHour(earliestSession.firstTimestamp.getHours()) : null,
+    },
+    weekendWarrior: {
+      percentage: totalSessions > 0 ? Math.round((weekendSessions / totalSessions) * 1000) / 10 : 0,
+      saturdaySessions: data.weekdayCounts[6],
+      sundaySessions: data.weekdayCounts[0],
+    },
+  };
+}
+
+// =============================================================================
+// ACHIEVEMENTS CALCULATIONS
+// =============================================================================
+
+function calculateAchievements(data) {
+  // Streaks
+  const dates = Object.keys(data.daySessions).sort();
+  let longestStreak = { days: 0, start: null, end: null };
+  let currentStreak = { days: 0, start: null };
+
+  if (dates.length > 0) {
+    let tempStreak = { days: 1, start: dates[0], end: dates[0] };
+
+    for (let i = 1; i < dates.length; i++) {
+      const prevDate = new Date(dates[i - 1]);
+      const currDate = new Date(dates[i]);
+      const diffDays = (currDate - prevDate) / 86400000;
+
+      if (diffDays === 1) {
+        tempStreak.days++;
+        tempStreak.end = dates[i];
+      } else {
+        if (tempStreak.days > longestStreak.days) {
+          longestStreak = { ...tempStreak };
+        }
+        tempStreak = { days: 1, start: dates[i], end: dates[i] };
+      }
+    }
+
+    if (tempStreak.days > longestStreak.days) {
+      longestStreak = { ...tempStreak };
+    }
+
+    // Current streak
+    const today = new Date();
+    today.setHours(0, 0, 0, 0);
+    const lastActiveDate = new Date(dates[dates.length - 1]);
+    const daysSinceLastActive = Math.floor((today - lastActiveDate) / 86400000);
+
+    if (daysSinceLastActive <= 1) {
+      currentStreak.days = 1;
+      currentStreak.start = dates[dates.length - 1];
+      for (let i = dates.length - 2; i >= 0; i--) {
+        const currDate = new Date(dates[i + 1]);
+        const prevDate = new Date(dates[i]);
+        if ((currDate - prevDate) / 86400000 === 1) {
+          currentStreak.days++;
+          currentStreak.start = dates[i];
+        } else {
+          break;
+        }
+      }
+    }
+  }
+
+  // Marathon days (5+ sessions)
+  const marathonDays = Object.entries(data.daySessions)
+    .filter(([, count]) => count >= 5)
+    .sort((a, b) => b[1] - a[1]);
+
+  // Busiest week
+  let busiestWeek = null;
+  const weeks = Object.entries(data.weekSessions);
+  if (weeks.length > 0) {
+    const busiest = weeks.reduce((max, [week, stats]) =>
+      stats.sessions > (max[1]?.sessions || 0) ? [week, stats] : max, [null, { sessions: 0 }]);
+
+    if (busiest[0]) {
+      busiestWeek = {
+        week: busiest[0],
+        dates: getWeekDateRange(busiest[0]),
+        sessions: busiest[1].sessions,
+        messages: busiest[1].messages,
+      };
+    }
+  }
+
+  return {
+    longestStreak: longestStreak.days > 0 ? longestStreak : null,
+    currentStreak: currentStreak.days > 0 ? currentStreak : null,
+    totalActiveDays: dates.length,
+    marathonDays: {
+      count: marathonDays.length,
+      maxSessions: marathonDays.length > 0 ? marathonDays[0][1] : 0,
+    },
+    busiestWeek,
+  };
+}
+
+// =============================================================================
+// SUPERLATIVES
+// =============================================================================
+
+function decodeProjectName(encodedName) {
+  const decoded = folderNameToPath(encodedName);
+  return path.basename(decoded);
+}
+
+function calculateSuperlatives(data) {
+  if (data.sessions.length === 0) {
+    return {
+      longestSession: null,
+      firstSession: null,
+      lastSession: null,
+    };
+  }
+
+  const sortedByMessages = [...data.sessions].sort((a, b) => b.messages - a.messages);
+  const sortedByTime = [...data.sessions].sort((a, b) => a.firstTimestamp - b.firstTimestamp);
+
+  const longest = sortedByMessages[0];
+  const first = sortedByTime[0];
+  const last = sortedByTime[sortedByTime.length - 1];
+
+  return {
+    longestSession: {
+      messages: longest.messages,
+      date: formatDate(longest.firstTimestamp),
+      project: decodeProjectName(longest.project),
+    },
+    firstSession: {
+      date: formatDate(first.firstTimestamp),
+      project: decodeProjectName(first.project),
+    },
+    lastSession: {
+      date: formatDate(last.firstTimestamp),
+      project: decodeProjectName(last.project),
+    },
+  };
+}
+
+// =============================================================================
+// MARKDOWN GENERATION
+// =============================================================================
+
+async function getDetailedRepoData(repos, author) {
+  // Run git log on all repos in parallel
+  const results = await Promise.all(
+    repos.map(async (repoPath) => {
+      const repoName = path.basename(repoPath);
+
+      // Get recent commits with Claude co-authoring
+      const gitArgs = [
+        '-C', repoPath,
+        'log', '--all',
+        '--grep=Co-Authored-By: Claude',
+        '--format=%H|%ai|%s',
+        '-n', '10', // Last 10 commits
+      ];
+
+      if (author) {
+        gitArgs.push(`--author=${author}`);
+      }
+
+      try {
+        const { stdout } = await execFileAsync('git', gitArgs, { maxBuffer: 10 * 1024 * 1024 });
+        const lines = stdout.trim().split('\n').filter(line => line);
+
+        const commits = lines.map(line => {
+          const [hash, dateStr, ...subjectParts] = line.split('|');
+          return {
+            hash: hash.slice(0, 7),
+            date: dateStr,
+            subject: subjectParts.join('|'),
+          };
+        });
+
+        if (commits.length > 0) {
+          return [repoName, { path: repoPath, commits }];
+        }
+      } catch {
+        // Skip repos with errors
+      }
+      return null;
+    })
+  );
+
+  return Object.fromEntries(results.filter(Boolean));
+}
+
+function generateMarkdownReport(repoData, projectMessages, user) {
+  const lines = [];
+
+  lines.push('# Claude Code Activity Report');
+  lines.push('');
+  lines.push(`Generated: ${new Date().toISOString()}`);
+  if (user.name) {
+    lines.push(`User: ${user.name} <${user.email || 'no email'}>`);
+  }
+  lines.push('');
+
+  // Repos and commits section
+  lines.push('## Repositories with Claude Commits');
+  lines.push('');
+
+  const repoNames = Object.keys(repoData).sort();
+
+  if (repoNames.length === 0) {
+    lines.push('*No repositories found with Claude co-authored commits.*');
+    lines.push('');
+  } else {
+    for (const repoName of repoNames) {
+      const repo = repoData[repoName];
+      lines.push(`### ${repoName}`);
+      lines.push('');
+      lines.push(`**Path:** \`${repo.path}\``);
+      lines.push('');
+      lines.push('**Recent commits with Claude:**');
+      lines.push('');
+
+      for (const commit of repo.commits) {
+        lines.push(`- \`${commit.hash}\` (${commit.date.split(' ')[0]}): ${commit.subject}`);
+      }
+      lines.push('');
+    }
+  }
+
+  // Session messages section
+  lines.push('## Recent Session Messages by Project');
+  lines.push('');
+
+  const projectNames = Object.keys(projectMessages).sort();
+
+  if (projectNames.length === 0) {
+    lines.push('*No recent session messages found.*');
+    lines.push('');
+  } else {
+    for (const projectName of projectNames) {
+      const project = projectMessages[projectName];
+      lines.push(`### ${projectName}`);
+      lines.push('');
+      lines.push(`**Last active:** ${project.lastActive.toISOString().split('T')[0]}`);
+      lines.push('');
+      lines.push('**Recent user messages:**');
+      lines.push('');
+
+      for (const msg of project.messages) {
+        const dateStr = msg.timestamp ? msg.timestamp.toISOString().split('T')[0] : 'unknown';
+        const truncatedText = msg.text.length > 200
+          ? msg.text.slice(0, 200) + '…'
+          : msg.text;
+        // Escape any markdown-sensitive characters and format as blockquote
+        const escapedText = truncatedText.replace(/\n/g, ' ').replace(/>/g, '\\>');
+        lines.push(`- (${dateStr}) "${escapedText}"`);
+      }
+      lines.push('');
+    }
+  }
+
+  return lines.join('\n');
+}
+
+async function writeMarkdownReport(outputPath) {
+  const user = getGitUser();
+
+  // Discover repos
+  let repos = discoverReposFromProjects();
+  if (repos.length === 0) {
+    const defaultPath = path.join(os.homedir(), 'code');
+    if (fs.existsSync(defaultPath)) {
+      repos = findGitReposInPath(defaultPath);
+    }
+  }
+
+  // Get detailed repo data
+  const repoData = await getDetailedRepoData(repos, user.name);
+
+  // Get recent messages per project
+  const projectMessages = getRecentMessagesPerProject();
+
+  // Generate markdown
+  const markdown = generateMarkdownReport(repoData, projectMessages, user);
+
+  // Determine output path - default to skill folder
+  const scriptDir = path.dirname(new URL(import.meta.url).pathname);
+  const finalPath = outputPath || path.join(scriptDir, 'activity-report.md');
+
+  // Ensure directory exists
+  const dir = path.dirname(finalPath);
+  if (!fs.existsSync(dir)) {
+    fs.mkdirSync(dir, { recursive: true });
+  }
+
+  // Write file
+  fs.writeFileSync(finalPath, markdown, 'utf-8');
+
+  return finalPath;
+}
+
+// =============================================================================
+// MAIN FUNCTIONS
+// =============================================================================
+
+async function getGitStats() {
+  const user = getGitUser();
+
+  // Try to discover repos from Claude projects first (faster)
+  let repos = discoverReposFromProjects();
+
+  // Fallback to filesystem scan if no repos found
+  if (repos.length === 0) {
+    const defaultPath = path.join(os.homedir(), 'code');
+    if (fs.existsSync(defaultPath)) {
+      repos = findGitReposInPath(defaultPath);
+    }
+  }
+
+  // Fetch commits from all repos in parallel
+  const commitArrays = await Promise.all(
+    repos.map(repo => getClaudeCommitsFromRepo(repo, user.name))
+  );
+
+  const allCommits = commitArrays.flat();
+  const stats = aggregateGitStats(allCommits);
+
+  return {
+    ...stats,
+    reposScanned: repos.length,
+  };
+}
+
+function getSessionStats() {
+  const sessionFiles = findSessionFiles();
+  const data = aggregateSessionStats(sessionFiles);
+
+  return {
+    totalSessions: data.sessions.length,
+    totalProjects: Object.keys(data.projectStats).length,
+    userMessages: data.totalUserMessages,
+    assistantMessages: data.totalAssistantMessages,
+    dateRange: {
+      first: data.firstTimestamp ? formatDate(data.firstTimestamp) : null,
+      last: data.lastTimestamp ? formatDate(data.lastTimestamp) : null,
+    },
+    topProjects: data.topProjects,
+    patterns: calculatePatterns(data),
+    achievements: calculateAchievements(data),
+    superlatives: calculateSuperlatives(data),
+    sessionFilesProcessed: sessionFiles.length,
+  };
+}
+
+async function getAllStats() {
+  const user = getGitUser();
+  const gitStats = await getGitStats();
+  const sessionStats = getSessionStats();
+
+  return {
+    user,
+    git: gitStats,
+    sessions: {
+      totalSessions: sessionStats.totalSessions,
+      totalProjects: sessionStats.totalProjects,
+      userMessages: sessionStats.userMessages,
+      assistantMessages: sessionStats.assistantMessages,
+      dateRange: sessionStats.dateRange,
+      topProjects: sessionStats.topProjects,
+    },
+    patterns: sessionStats.patterns,
+    achievements: sessionStats.achievements,
+    superlatives: sessionStats.superlatives,
+    meta: {
+      generatedAt: new Date().toISOString(),
+      gitReposScanned: gitStats.reposScanned,
+      sessionFilesProcessed: sessionStats.sessionFilesProcessed,
+    },
+  };
+}
+
+// =============================================================================
+// CLI
+// =============================================================================
+
+function showHelp() {
+  console.log(`
+Unified statistics extraction for Thinkback
+
+Consolidates git commits, session data, and time patterns into a single
+narrative-friendly JSON output. Works on any user's machine.
+
+Usage:
+  node get_all_stats.js              # Full output as JSON
+  node get_all_stats.js --git-only   # Just git stats (long-term)
+  node get_all_stats.js --recent-only # Just session stats (recent ~30 days)
+  node get_all_stats.js --markdown   # Also write markdown report to skill folder
+  node get_all_stats.js --markdown-path=/path/to/output.md  # Write markdown to custom path
+  node get_all_stats.js --help
+
+Output includes:
+  - User identity (git config)
+  - Git stats: commits with Claude, repos, time patterns
+  - Session stats: messages, projects (recent ~30 days)
+  - Time patterns: night owl, early bird, weekend warrior
+  - Achievements: streaks, marathon days, busiest week
+  - Superlatives: longest session, first/last session
+
+Markdown report includes:
+  - Every repo with Claude co-authored commits
+  - Recent commits per repo (up to 10)
+  - Recent user messages per project (up to 5)
+`);
+}
+
+async function main() {
+  const args = parseArgs(process.argv);
+
+  if (args.help) {
+    showHelp();
+    process.exit(0);
+  }
+
+  let output;
+
+  switch (args.mode) {
+    case 'git':
+      output = { user: getGitUser(), git: await getGitStats() };
+      break;
+    case 'recent':
+      output = { user: getGitUser(), ...getSessionStats() };
+      break;
+    case 'all':
+    default:
+      output = await getAllStats();
+      break;
+  }
+
+  console.log(JSON.stringify(output, null, 2));
+
+  // Generate markdown report if requested
+  if (args.markdown) {
+    const reportPath = await writeMarkdownReport(args.markdownPath);
+    console.error(`\nMarkdown report written to: ${reportPath}`);
+  }
+}
+
+// Run CLI if executed directly
+const isMain = process.argv[1]?.endsWith('get_all_stats.js');
+if (isMain) {
+  main().catch(err => {
+    console.error('Error:', err.message);
+    process.exit(1);
+  });
+}
+
+// =============================================================================
+// EXPORTS
+// =============================================================================
+
+export { getAllStats, getGitStats, getSessionStats, getGitUser, writeMarkdownReport };

+ 390 - 0
plugins/thinkback/skills/thinkback/scripts/helpers_demo.js

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

+ 145 - 0
plugins/thinkback/skills/thinkback/scripts/test_intro.js

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

+ 142 - 0
plugins/thinkback/skills/thinkback/scripts/validate.js

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

+ 399 - 0
plugins/thinkback/skills/thinkback/templates/awards-show-template.js

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

+ 300 - 0
plugins/thinkback/skills/thinkback/templates/cozy-template.js

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

+ 412 - 0
plugins/thinkback/skills/thinkback/templates/morning-news-template.js

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

+ 443 - 0
plugins/thinkback/skills/thinkback/templates/rpg-quest-template.js

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

+ 440 - 0
plugins/thinkback/skills/thinkback/vibes/awards-show-vibe.md

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

+ 92 - 0
plugins/thinkback/skills/thinkback/vibes/cozy-vibe.md

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

+ 443 - 0
plugins/thinkback/skills/thinkback/vibes/morning-news-vibe.md

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

+ 5 - 0
plugins/thinkback/skills/thinkback/vibes/other-vibe.md

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

+ 603 - 0
plugins/thinkback/skills/thinkback/vibes/rpg-quest-vibe.md

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

+ 502 - 0
plugins/thinkback/skills/thinkback/year_in_review.html

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

+ 373 - 0
plugins/thinkback/skills/thinkback/year_in_review_template.js

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