claude-rpc 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -259,6 +259,7 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
259
259
  | `session-card` | Recap card for the current session (`--out`) |
260
260
  | `statusline` | One-line status for tmux/shell prompts (`--template`) |
261
261
  | `mcp` | Run as an MCP server — expose your stats to Claude Code |
262
+ | `wrapped` | Open your animated year-in-review (Claude Wrapped) |
262
263
  | `private` / `public` / `privacy` | Per-cwd visibility toggles + status |
263
264
  | `community` | Opt-in community totals — `on` \| `off` \| `status` \| `report` |
264
265
  | `doctor` | Diagnostic checklist with one-line fix hints |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -1141,6 +1141,7 @@ function help() {
1141
1141
  ['calendar', 'Year activity heatmap SVG (--out --gist)'],
1142
1142
  ['session-card', 'Recap card for the current session (--out)'],
1143
1143
  ['mcp', 'Run as an MCP server — expose your stats to Claude Code'],
1144
+ ['wrapped', 'Open your animated year-in-review (Claude Wrapped)'],
1144
1145
  ['private', 'Mark the current directory as private (hide from Discord)'],
1145
1146
  ['public', 'Un-mark the current directory'],
1146
1147
  ['privacy', 'Show resolved visibility for the current directory'],
@@ -1218,6 +1219,7 @@ const packagedDefault = IS_PACKAGED && !cmd;
1218
1219
  case 'calendar': await doCalendar(process.argv.slice(3)); break;
1219
1220
  case 'session-card': await doSessionCard(process.argv.slice(3)); break;
1220
1221
  case 'mcp': await doMcp(); break;
1222
+ case 'wrapped': process.env.CLAUDE_RPC_OPEN_PATH = '/wrapped'; await import('./server/index.js'); break;
1221
1223
  case 'private': doPrivate(); break;
1222
1224
  case 'public': doPublic(); break;
1223
1225
  case 'privacy': doPrivacy(); break;
package/src/server/api.js CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { basename } from 'node:path';
6
6
  import { readState } from '../state.js';
7
- import { buildVars, fillTemplate, applyIdle, framePasses } from '../format.js';
7
+ import { buildVars, fillTemplate, applyIdle, framePasses, humanModel } from '../format.js';
8
8
  import { readAggregate, findLiveSessions, dayKey } from '../scanner.js';
9
9
  import { loadConfig as loadSharedConfig } from '../config.js';
10
10
 
@@ -154,6 +154,47 @@ export function snapshot() {
154
154
  };
155
155
  }
156
156
 
157
+ // Curated payload for the animated /wrapped year-in-review page. One flat
158
+ // object the client turns into story slides — all the headline lifetime stats.
159
+ const WRAPPED_WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
160
+ export function wrappedData() {
161
+ const agg = readAggregate() || {};
162
+ const fresh = (agg.inputTokens || 0) + (agg.outputTokens || 0);
163
+ const cache = (agg.cacheReadTokens || 0) + (agg.cacheWriteTokens || 0);
164
+ const tokens = fresh + cache;
165
+ const langs = Object.entries(agg.languages || {}).sort((a, b) => (b[1].edits || 0) - (a[1].edits || 0));
166
+ const top = (agg.topEditedFiles || [])[0] || null;
167
+ let peakWd = null;
168
+ for (const [k, v] of Object.entries(agg.byWeekday || {})) {
169
+ if (!peakWd || (v.activeMs || 0) > peakWd.ms) peakWd = { day: Number(k), ms: v.activeMs || 0 };
170
+ }
171
+ return {
172
+ generatedAt: Date.now(),
173
+ activeMs: agg.activeMs || 0,
174
+ sessions: agg.sessions || 0,
175
+ prompts: agg.userMessages || 0,
176
+ toolCalls: agg.toolCalls || 0,
177
+ tokens, freshTokens: fresh, cacheTokens: cache,
178
+ cachePct: tokens > 0 ? Math.round((cache / tokens) * 100) : 0,
179
+ streak: agg.streak || 0,
180
+ longestStreak: agg.longestStreak || 0,
181
+ daysSinceFirst: agg.daysSinceFirst || 0,
182
+ topLanguage: langs[0] ? { name: langs[0][0], edits: langs[0][1].edits || 0 } : null,
183
+ languages: langs.slice(0, 5).map(([name, v]) => ({ name, edits: v.edits || 0 })),
184
+ hotspot: top ? { name: basename(String(top.path || '')), count: top.count, daysSinceLastEdit: top.daysSinceLastEdit } : null,
185
+ peakHour: agg.peakHour && agg.peakHour.hour != null ? agg.peakHour.hour : null,
186
+ peakWeekday: peakWd ? { name: WRAPPED_WEEKDAYS[peakWd.day], hours: peakWd.ms / 3_600_000 } : null,
187
+ modelSplit: (agg.modelSplit || []).slice(0, 4).map((m) => ({ model: humanModel(m.model) || m.model, costPct: m.costPct || 0, tokenPct: m.tokenPct || 0 })),
188
+ linesAdded: agg.linesAdded || 0,
189
+ linesNet: agg.linesNet ?? ((agg.linesAdded || 0) - (agg.linesRemoved || 0)),
190
+ cost: agg.estimatedCost || 0,
191
+ bestDay: agg.bestDay ? { date: agg.bestDay.day, hours: (agg.bestDay.activeMs || 0) / 3_600_000 } : null,
192
+ uniqueFiles: agg.uniqueFiles || 0,
193
+ subagentRuns: agg.subagentRuns || 0,
194
+ notifications: agg.notifications || 0,
195
+ };
196
+ }
197
+
157
198
  export function projectDrilldown(name) {
158
199
  const agg = readAggregate() || {};
159
200
  const projects = agg.projects || {};
@@ -324,3 +324,13 @@ footer .pulse-dot { background: var(--grass); border: 1.5px solid var(--ink); }
324
324
  .rotation-list { grid-template-columns: 1fr; }
325
325
  .drawer { width: 100%; }
326
326
  }
327
+
328
+ /* Wrapped link (v0.11) */
329
+ .wrapped-link {
330
+ font-family: var(--font-mono, 'JetBrains Mono', monospace);
331
+ font-size: 12px; font-weight: 700; letter-spacing: 0.5px;
332
+ text-decoration: none; color: var(--rust, #c2491e);
333
+ border: 1.5px solid var(--rust, #c2491e); border-radius: 4px;
334
+ padding: 5px 10px; white-space: nowrap; transition: all 0.15s ease;
335
+ }
336
+ .wrapped-link:hover { background: var(--rust, #c2491e); color: var(--paper, #f4ede0); }
@@ -29,6 +29,7 @@
29
29
  <button data-range="1y">1y</button>
30
30
  <button data-range="all">All</button>
31
31
  </div>
32
+ <a class="wrapped-link" href="/wrapped" title="Your animated year-in-review">✦ Wrapped</a>
32
33
  <button class="theme-btn" id="theme-btn" title="Toggle theme">◐</button>
33
34
  <span class="model" id="model">—</span>
34
35
  <span class="status"><span class="dot" id="dot"></span><span id="statustext">—</span></span>
@@ -0,0 +1,227 @@
1
+ (() => {
2
+ 'use strict';
3
+ const app = document.getElementById('app');
4
+
5
+ // ── format helpers ───────────────────────────────────────────
6
+ const fmtNum = (n) => {
7
+ n = +n || 0;
8
+ if (n < 1000) return String(Math.round(n));
9
+ if (n < 1e6) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'k';
10
+ if (n < 1e9) return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
11
+ return (n / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B';
12
+ };
13
+ const fmtHours = (ms) => { const h = (ms || 0) / 3.6e6; return h < 1 ? Math.round(h * 60) + 'm' : h < 10 ? h.toFixed(1) + 'h' : Math.round(h) + 'h'; };
14
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
15
+ const GIF = 'https://cdn.qualit.ly/';
16
+
17
+ // anim-stagger style helper
18
+ let _i = 0; const A = () => `style="--i:${_i++}"`;
19
+
20
+ // ── build the story slides from the wrapped payload ──────────
21
+ function buildSlides(d) {
22
+ const out = [];
23
+ const hours = (d.activeMs || 0) / 3.6e6;
24
+ const perDay = d.prompts / Math.max(1, d.daysSinceFirst);
25
+ const pad2 = (h) => String(h).padStart(2, '0');
26
+
27
+ const S = (cls, body, dur) => { _i = 0; out.push({ cls, html: body, dur: dur || 5200 }); };
28
+
29
+ // 1. intro
30
+ S('ink', `
31
+ <img class="gif anim" ${A()} src="${GIF}clawd-working-building.gif" alt="" />
32
+ <div class="kicker anim" ${A()}>claude-rpc presents</div>
33
+ <div class="big anim" ${A()}>Your Year<br/>on Claude Code</div>
34
+ <div class="sub anim" ${A()}>${esc(d.daysSinceFirst)} days in the making · tap →</div>`, 4200);
35
+
36
+ // 2. hours
37
+ S('rust', `
38
+ <div class="kicker anim" ${A()}>you spent</div>
39
+ <div class="huge anim" ${A()}><span data-count="${hours}" data-fmt="1dp">0</span></div>
40
+ <div class="unit anim" ${A()}>hours with Claude</div>
41
+ <div class="sub anim" ${A()}>across ${fmtNum(d.sessions)} sessions${d.bestDay ? ` · your biggest day was ${fmtHours(d.bestDay.hours * 3.6e6)}` : ''}</div>`);
42
+
43
+ // 3. sessions + streak
44
+ S('gold', `
45
+ <div class="kicker anim" ${A()}>you opened</div>
46
+ <div class="huge anim" ${A()}><span data-count="${d.sessions}" data-fmt="num">0</span></div>
47
+ <div class="unit anim" ${A()}>sessions</div>
48
+ <div class="sub anim" ${A()}>longest streak: <b>${d.longestStreak} days</b> in a row${d.streak ? ` · ${d.streak} going now` : ''}</div>`);
49
+
50
+ // 4. prompts
51
+ S('grass', `
52
+ <div class="kicker anim" ${A()}>you asked</div>
53
+ <div class="huge anim" ${A()}><span data-count="${d.prompts}" data-fmt="num">0</span></div>
54
+ <div class="unit anim" ${A()}>prompts</div>
55
+ <div class="sub anim" ${A()}>that's about <b>${perDay < 10 ? perDay.toFixed(1) : Math.round(perDay)}</b> a day, every day</div>`);
56
+
57
+ // 5. tokens
58
+ S('blurple', `
59
+ <div class="kicker anim" ${A()}>you moved</div>
60
+ <div class="huge anim" ${A()}><span data-count="${d.tokens}" data-fmt="num">0</span></div>
61
+ <div class="unit anim" ${A()}>tokens</div>
62
+ <div class="sub anim" ${A()}>${d.cachePct}% reused from cache — Claude has a good memory</div>`);
63
+
64
+ // 6. top language
65
+ if (d.topLanguage) S('plum', `
66
+ <div class="kicker anim" ${A()}>you mostly spoke</div>
67
+ <div class="anim" ${A()}><span class="tapebadge pop">${esc(d.topLanguage.name)}</span></div>
68
+ <div class="sub anim" ${A()}>${fmtNum(d.topLanguage.edits)} edits — your number-one language</div>`);
69
+
70
+ // 7. hotspot file
71
+ if (d.hotspot) {
72
+ const age = d.hotspot.daysSinceLastEdit == null ? '' : d.hotspot.daysSinceLastEdit === 0 ? ' · still warm today' : ` · last touched ${d.hotspot.daysSinceLastEdit}d ago`;
73
+ S('ink', `
74
+ <div class="kicker anim" ${A()}>you kept coming back to</div>
75
+ <div class="big anim" ${A()} style="font-family:'JetBrains Mono',monospace;font-size:clamp(30px,9vw,68px);--i:1">${esc(d.hotspot.name)}</div>
76
+ <div class="sub anim" ${A()}>${fmtNum(d.hotspot.count)} edits${age}</div>`);
77
+ }
78
+
79
+ // 8. peak time
80
+ if (d.peakWeekday && d.peakHour != null) S('rust', `
81
+ <div class="kicker anim" ${A()}>you were in the zone on</div>
82
+ <div class="big anim" ${A()}>${esc(d.peakWeekday.name)}s</div>
83
+ <div class="unit anim" ${A()}>around ${pad2(d.peakHour)}:00</div>
84
+ <div class="sub anim" ${A()}>your most productive window</div>`);
85
+
86
+ // 9. model split
87
+ if (d.modelSplit && d.modelSplit.length) {
88
+ const rows = d.modelSplit.filter((m) => m.costPct > 0).map((m) => {
89
+ const pct = Math.round(m.costPct * 100);
90
+ return `<div class="msrow anim" ${A()}><span class="lbl">${esc(m.model)}</span><span class="mstrack"><span class="msfill" style="--w:${pct}%"></span></span><span class="pct">${pct}%</span></div>`;
91
+ }).join('');
92
+ S('paper', `
93
+ <div class="kicker anim" ${A()} style="--i:0">your models, by spend</div>
94
+ <div class="msplit">${rows}</div>
95
+ <div class="sub anim" ${A()} style="opacity:.7">${esc((d.modelSplit[0] || {}).model || '')} did most of the heavy lifting</div>`);
96
+ }
97
+
98
+ // 10. lines
99
+ if (d.linesAdded) S('grass', `
100
+ <div class="kicker anim" ${A()}>together you wrote</div>
101
+ <div class="huge anim" ${A()}><span data-count="${d.linesAdded}" data-fmt="num">0</span></div>
102
+ <div class="unit anim" ${A()}>lines of code</div>
103
+ <div class="sub anim" ${A()}>${d.linesNet >= 0 ? '+' : '−'}${fmtNum(Math.abs(d.linesNet))} net after the dust settled</div>`);
104
+
105
+ // 11. finale summary
106
+ const cell = (k, v, cls) => `<div><div class="cellk">${k}</div><div class="cellv ${cls || ''}">${v}</div></div>`;
107
+ S('ink', `
108
+ <div class="summary">
109
+ <div class="card pop" style="--i:0">
110
+ <h2>Your Year on Claude Code</h2>
111
+ <div class="meta">day ${d.daysSinceFirst} · ${new Date(d.generatedAt).toISOString().slice(0, 10)}</div>
112
+ <div class="grid">
113
+ ${cell('Time', fmtHours(d.activeMs), 'rust')}
114
+ ${cell('Sessions', fmtNum(d.sessions))}
115
+ ${cell('Prompts', fmtNum(d.prompts))}
116
+ ${cell('Tokens', fmtNum(d.tokens))}
117
+ ${cell('Streak', (d.longestStreak || 0) + 'd')}
118
+ ${cell('Top lang', d.topLanguage ? esc(d.topLanguage.name) : '—')}
119
+ ${cell('Lines', (d.linesNet >= 0 ? '+' : '−') + fmtNum(Math.abs(d.linesNet)), 'grass')}
120
+ ${cell('Hotspot', d.hotspot ? esc(d.hotspot.name) : '—')}
121
+ </div>
122
+ <div class="foot">made with claude-rpc · github.com/rar-file/claude-rpc</div>
123
+ </div>
124
+ <div class="actions">
125
+ <button class="btn primary" id="w-replay">↺ replay</button>
126
+ <a class="btn" id="w-poster" href="/api/card.svg?range=all" target="_blank">poster ↗</a>
127
+ <button class="btn" id="w-copy">copy link</button>
128
+ </div>
129
+ </div>
130
+ <div class="hint">screenshot the card to share your wrapped</div>`, 9_000_000); // last slide: effectively no auto-advance
131
+
132
+ return out;
133
+ }
134
+
135
+ // ── story engine ─────────────────────────────────────────────
136
+ function mount(slides) {
137
+ _i = 0;
138
+ const bars = slides.map(() => `<div class="bar"><i></i></div>`).join('');
139
+ const slideEls = slides.map((s) => `<section class="slide ${s.cls}">${s.html}</section>`).join('');
140
+ app.innerHTML = `<div class="story" id="story">
141
+ <div class="bars">${bars}</div>
142
+ <div class="brandtag">claude wrapped</div>
143
+ ${slideEls}
144
+ <div class="tap left" id="tapL"></div>
145
+ <div class="tap right" id="tapR"></div>
146
+ <div class="hint" id="navhint">← → · space to pause</div>
147
+ </div>`;
148
+
149
+ const story = document.getElementById('story');
150
+ const barEls = [...story.querySelectorAll('.bar')];
151
+ const els = [...story.querySelectorAll('.slide')];
152
+ let idx = -1, timer = null, paused = false;
153
+
154
+ function runCountups(slide) {
155
+ slide.querySelectorAll('[data-count]').forEach((node) => {
156
+ const target = parseFloat(node.dataset.count) || 0, fmt = node.dataset.fmt || 'int';
157
+ const dur = 1300, t0 = performance.now();
158
+ const val = (v) => fmt === 'num' ? fmtNum(v) : fmt === '1dp' ? (v < 10 ? v.toFixed(1) : String(Math.round(v))) : String(Math.round(v));
159
+ function tick(t) {
160
+ const p = Math.min(1, (t - t0) / dur), e = 1 - Math.pow(1 - p, 3);
161
+ node.textContent = val(target * e);
162
+ if (p < 1) requestAnimationFrame(tick); else node.textContent = val(target);
163
+ }
164
+ requestAnimationFrame(tick);
165
+ });
166
+ }
167
+
168
+ function go(i) {
169
+ if (i < 0 || i >= slides.length) return;
170
+ idx = i; paused = false; story.classList.remove('paused');
171
+ els.forEach((s, k) => s.classList.toggle('active', k === i));
172
+ barEls.forEach((b, k) => { b.classList.remove('active', 'done'); if (k < i) b.classList.add('done'); });
173
+ const ab = barEls[i];
174
+ ab.style.setProperty('--dur', (slides[i].dur || 5200) + 'ms');
175
+ void ab.offsetWidth; // reflow → restart fill animation
176
+ ab.classList.add('active');
177
+ // The finale carries its own "screenshot to share" hint — hide the
178
+ // nav hint there so the two don't stack at the bottom edge.
179
+ const nh = document.getElementById('navhint');
180
+ if (nh) nh.style.visibility = (i === slides.length - 1) ? 'hidden' : 'visible';
181
+ setTimeout(() => { if (idx === i) runCountups(els[i]); }, 220);
182
+ clearTimeout(timer);
183
+ if (i < slides.length - 1) timer = setTimeout(() => { if (!paused) go(idx + 1); }, slides[i].dur || 5200);
184
+ if (i === slides.length - 1) wireFinale();
185
+ }
186
+ const next = () => go(Math.min(idx + 1, slides.length - 1));
187
+ const prev = () => go(Math.max(idx - 1, 0));
188
+ function pauseToggle() {
189
+ paused = !paused; story.classList.toggle('paused', paused);
190
+ if (paused) clearTimeout(timer);
191
+ else if (idx < slides.length - 1) timer = setTimeout(() => go(idx + 1), slides[idx].dur || 5200);
192
+ }
193
+
194
+ document.getElementById('tapL').onclick = prev;
195
+ document.getElementById('tapR').onclick = next;
196
+ document.addEventListener('keydown', (e) => {
197
+ if (e.key === 'ArrowRight') next();
198
+ else if (e.key === 'ArrowLeft') prev();
199
+ else if (e.key === ' ') { e.preventDefault(); pauseToggle(); }
200
+ });
201
+
202
+ let wired = false;
203
+ function wireFinale() {
204
+ if (wired) return; wired = true;
205
+ const r = document.getElementById('w-replay');
206
+ const c = document.getElementById('w-copy');
207
+ if (r) r.onclick = (e) => { e.stopPropagation(); go(0); };
208
+ if (c) c.onclick = (e) => {
209
+ e.stopPropagation();
210
+ navigator.clipboard?.writeText(location.href).then(() => { c.textContent = 'copied ✓'; setTimeout(() => c.textContent = 'copy link', 1500); }).catch(() => {});
211
+ };
212
+ }
213
+
214
+ go(0);
215
+ }
216
+
217
+ // ── boot ─────────────────────────────────────────────────────
218
+ fetch('/api/wrapped').then((r) => r.json()).then((d) => {
219
+ if (!d || !d.sessions) {
220
+ app.innerHTML = `<div class="boot">no data yet — run <code style="opacity:.9">claude-rpc scan</code> first, then refresh.</div>`;
221
+ return;
222
+ }
223
+ mount(buildSlides(d));
224
+ }).catch(() => {
225
+ app.innerHTML = `<div class="boot">couldn't load your year. is the daemon's <code>serve</code> running?</div>`;
226
+ });
227
+ })();
@@ -0,0 +1,121 @@
1
+ /* Claude Wrapped — full-screen, animated, Spotify-Wrapped-style story. */
2
+ * { margin: 0; padding: 0; box-sizing: border-box; }
3
+ :root {
4
+ --ink: #1a1611; --paper: #f4ede0; --rust: #c2491e; --rust3: #f08a4a;
5
+ --tape: #f2d76e; --grass: #4a9462; --blurple: #5865f2; --plum: #7b3f6b;
6
+ }
7
+ html, body { height: 100%; overflow: hidden; background: var(--ink); }
8
+ body {
9
+ font-family: 'Space Grotesk', system-ui, sans-serif;
10
+ color: var(--paper); -webkit-font-smoothing: antialiased;
11
+ user-select: none; -webkit-user-select: none;
12
+ }
13
+ .boot {
14
+ position: fixed; inset: 0; display: grid; place-items: center;
15
+ font-family: 'JetBrains Mono', monospace; color: var(--paper); opacity: 0.7;
16
+ letter-spacing: 1px;
17
+ }
18
+ .dots::after { content: ''; animation: dots 1.4s steps(4, end) infinite; }
19
+ @keyframes dots { 0% { content: ''; } 25% { content: '.'; } 50% { content: '..'; } 75% { content: '...'; } }
20
+
21
+ /* ── story shell ─────────────────────────────────────────────── */
22
+ .story { position: fixed; inset: 0; overflow: hidden; }
23
+ .bars {
24
+ position: absolute; top: 0; left: 0; right: 0; z-index: 30;
25
+ display: flex; gap: 5px; padding: 14px 16px;
26
+ }
27
+ .bar { flex: 1; height: 3px; border-radius: 3px; background: rgba(255,255,255,0.28); overflow: hidden; }
28
+ .bar > i { display: block; height: 100%; width: 0; background: rgba(255,255,255,0.95); }
29
+ .bar.done > i { width: 100%; }
30
+ .bar.active > i { animation: fill var(--dur, 5500ms) linear forwards; }
31
+ .story.paused .bar.active > i { animation-play-state: paused; }
32
+ @keyframes fill { to { width: 100%; } }
33
+
34
+ .brandtag {
35
+ position: absolute; top: 26px; left: 0; right: 0; z-index: 25; text-align: center;
36
+ font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 3px;
37
+ text-transform: uppercase; opacity: 0.65; pointer-events: none;
38
+ }
39
+
40
+ /* nav tap zones */
41
+ .tap { position: absolute; top: 0; bottom: 0; width: 35%; z-index: 20; cursor: pointer; }
42
+ .tap.left { left: 0; } .tap.right { right: 0; width: 65%; }
43
+
44
+ /* ── slides ──────────────────────────────────────────────────── */
45
+ .slide {
46
+ position: absolute; inset: 0; display: flex; flex-direction: column;
47
+ align-items: center; justify-content: center; text-align: center;
48
+ padding: 64px 30px; gap: 14px;
49
+ opacity: 0; visibility: hidden; transition: opacity 0.45s ease;
50
+ }
51
+ .slide.active { opacity: 1; visibility: visible; }
52
+ .slide.ink { background: var(--ink); color: var(--paper); }
53
+ .slide.rust { background: var(--rust); color: var(--paper); }
54
+ .slide.gold { background: var(--tape); color: var(--ink); }
55
+ .slide.grass { background: var(--grass); color: var(--paper); }
56
+ .slide.blurple{ background: var(--blurple);color: var(--paper); }
57
+ .slide.plum { background: var(--plum); color: var(--paper); }
58
+ .slide.paper { background: var(--paper); color: var(--ink); }
59
+ .slide::after { /* subtle dot grid */
60
+ content: ''; position: absolute; inset: 0; pointer-events: none; opacity: 0.10;
61
+ background-image: radial-gradient(currentColor 1px, transparent 1.4px);
62
+ background-size: 26px 26px;
63
+ }
64
+
65
+ /* entrance animation — children with .anim rise + fade in, staggered */
66
+ .slide .anim { opacity: 0; transform: translateY(26px); }
67
+ .slide.active .anim { animation: rise 0.7s cubic-bezier(.2,.7,.2,1) forwards; animation-delay: calc(var(--i, 0) * 0.13s + 0.1s); }
68
+ @keyframes rise { to { opacity: 1; transform: none; } }
69
+ .slide.active .pop { animation: pop 0.6s cubic-bezier(.2,1.4,.4,1) forwards; animation-delay: calc(var(--i, 0) * 0.13s + 0.15s); }
70
+ @keyframes pop { 0% { opacity: 0; transform: scale(0.6); } 100% { opacity: 1; transform: scale(1); } }
71
+
72
+ .kicker { font-family: 'JetBrains Mono', monospace; font-size: clamp(13px, 3.6vw, 17px); letter-spacing: 1px; opacity: 0.85; max-width: 16ch; line-height: 1.5; }
73
+ .huge { font-weight: 800; font-size: clamp(64px, 22vw, 168px); line-height: 0.92; letter-spacing: -4px; }
74
+ .big { font-weight: 800; font-size: clamp(40px, 13vw, 104px); line-height: 0.98; letter-spacing: -2px; }
75
+ .sub { font-family: 'JetBrains Mono', monospace; font-size: clamp(13px, 3.6vw, 16px); opacity: 0.8; max-width: 26ch; line-height: 1.6; }
76
+ .unit { font-weight: 700; font-size: clamp(22px, 7vw, 44px); letter-spacing: -1px; }
77
+ .tapebadge {
78
+ display: inline-block; background: var(--tape); color: var(--ink);
79
+ font-family: 'JetBrains Mono', monospace; font-weight: 700; letter-spacing: 1px;
80
+ padding: 7px 14px; border: 2px solid var(--ink); transform: rotate(-2deg);
81
+ font-size: clamp(15px, 4.5vw, 22px); text-transform: uppercase;
82
+ }
83
+ .gif { width: clamp(96px, 28vw, 150px); image-rendering: auto; }
84
+
85
+ /* model split bars */
86
+ .msplit { width: min(86vw, 420px); display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
87
+ .msrow { display: flex; align-items: center; gap: 10px; font-family: 'JetBrains Mono', monospace; font-size: 13px; }
88
+ .msrow .lbl { width: 88px; text-align: right; opacity: 0.9; }
89
+ .mstrack { flex: 1; height: 14px; background: rgba(0,0,0,0.18); border-radius: 8px; overflow: hidden; }
90
+ .slide.gold .mstrack, .slide.paper .mstrack { background: rgba(0,0,0,0.12); }
91
+ .msfill { display: block; height: 100%; width: 0; background: currentColor; border-radius: 8px;
92
+ transition: width 0.95s cubic-bezier(.2,.7,.2,1) 0.35s; }
93
+ .slide.active .msfill { width: var(--w, 0%); }
94
+ .msrow .pct { width: 44px; opacity: 0.85; }
95
+
96
+ /* ── finale summary ──────────────────────────────────────────── */
97
+ /* z-index lifts the card + action buttons ABOVE the full-screen .tap nav
98
+ zones (z-index 20) — otherwise the tap layer swallows every button click. */
99
+ .summary { width: min(92vw, 460px); position: relative; z-index: 24; }
100
+ .card {
101
+ background: var(--paper); color: var(--ink); border: 2px solid var(--ink);
102
+ box-shadow: 8px 9px 0 rgba(0,0,0,0.35); padding: 24px 24px 20px; text-align: left;
103
+ }
104
+ .card h2 { font-size: clamp(26px, 8vw, 36px); letter-spacing: -1.5px; margin-bottom: 2px; }
105
+ .card .meta { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #5c5147; margin-bottom: 16px; }
106
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 18px; }
107
+ .cellk { font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; letter-spacing: 1.5px; color: #5c5147; text-transform: uppercase; }
108
+ .cellv { font-weight: 800; font-size: clamp(20px, 6vw, 26px); letter-spacing: -0.5px; line-height: 1.1; }
109
+ .cellv.rust { color: var(--rust); } .cellv.grass { color: var(--grass); }
110
+ .foot { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: #8a7c6d; margin-top: 16px; border-top: 1px dashed #c9bca3; padding-top: 10px; }
111
+ .actions { display: flex; gap: 10px; justify-content: center; margin-top: 22px; flex-wrap: wrap; }
112
+ .btn {
113
+ font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: 13px;
114
+ padding: 11px 18px; border: 2px solid var(--paper); background: transparent; color: var(--paper);
115
+ cursor: pointer; letter-spacing: 0.5px; border-radius: 2px;
116
+ }
117
+ .btn.primary { background: var(--paper); color: var(--ink); }
118
+ .btn:hover { transform: translateY(-1px); }
119
+
120
+ .hint { position: absolute; bottom: 16px; left: 0; right: 0; text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 10px; opacity: 0.5; z-index: 25; pointer-events: none; }
121
+ @media (max-width: 420px) { .grid { gap: 12px 14px; } }
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>Your Year on Claude Code · claude-rpc</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
10
+ <style>{{STYLES}}</style>
11
+ </head>
12
+ <body>
13
+ <div id="app"><div class="boot">building your year<span class="dots"></span></div></div>
14
+ <script>{{SCRIPT}}</script>
15
+ </body>
16
+ </html>
@@ -16,11 +16,12 @@ import { exec } from 'node:child_process';
16
16
  import { ROUTES, JSON_HEADERS } from './routes.js';
17
17
  import { projectDrilldown, dayDetail } from './api.js';
18
18
  import { sseClients, watchSources } from './sse.js';
19
- import { buildHtml } from './page.js';
19
+ import { buildHtml, buildWrappedHtml } from './page.js';
20
20
 
21
21
  // Pre-compose the HTML once at startup — the only dynamic bit is the port
22
22
  // (used in a breadcrumb), which is fixed for the life of the daemon.
23
23
  const HTML = buildHtml({ port: Number(process.env.CLAUDE_RPC_PORT) || 47474 });
24
+ const WRAPPED_HTML = buildWrappedHtml();
24
25
 
25
26
  const PORT = Number(process.env.CLAUDE_RPC_PORT) || 47474;
26
27
 
@@ -70,6 +71,13 @@ const server = createServer((req, res) => {
70
71
  const handler = ROUTES.get(key);
71
72
  if (handler) return handler(req, res, { query });
72
73
 
74
+ // Animated year-in-review.
75
+ if (req.method === 'GET' && (path === '/wrapped' || path === '/wrapped.html')) {
76
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
77
+ res.end(WRAPPED_HTML);
78
+ return;
79
+ }
80
+
73
81
  // Page.
74
82
  if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
75
83
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
@@ -83,9 +91,13 @@ const server = createServer((req, res) => {
83
91
  watchSources();
84
92
 
85
93
  server.listen(PORT, '127.0.0.1', () => {
86
- const url = `http://127.0.0.1:${PORT}`;
87
- console.log(`◆ Claude RPC dashboard: ${url}`);
88
- console.log(' Ctrl-C to stop.');
94
+ const base = `http://127.0.0.1:${PORT}`;
95
+ // `claude-rpc wrapped` sets CLAUDE_RPC_OPEN_PATH=/wrapped to land on the
96
+ // animated year-in-review instead of the dashboard.
97
+ const openPath = process.env.CLAUDE_RPC_OPEN_PATH || '';
98
+ const url = base + openPath;
99
+ console.log(`◆ Claude RPC dashboard: ${base}`);
100
+ console.log(` ${openPath ? 'opening ' + url + ' · ' : ''}Ctrl-C to stop.`);
89
101
  if (!process.env.CLAUDE_RPC_NO_OPEN) {
90
102
  const opener = process.platform === 'win32' ? `start "" "${url}"`
91
103
  : process.platform === 'darwin' ? `open "${url}"`
@@ -19,6 +19,10 @@ const TEMPLATE = loadAsset('dashboard.html');
19
19
  const STYLES = loadAsset('dashboard.css');
20
20
  const SCRIPT = loadAsset('dashboard.client.js');
21
21
 
22
+ const WRAPPED_TEMPLATE = loadAsset('wrapped.html');
23
+ const WRAPPED_STYLES = loadAsset('wrapped.css');
24
+ const WRAPPED_SCRIPT = loadAsset('wrapped.client.js');
25
+
22
26
  function buildHtml({ port }) {
23
27
  // Replacer FUNCTIONS, not strings: the client JS is full of `$(id)` DOM
24
28
  // accessors and the CSS could carry `$`, both of which String.prototype
@@ -30,4 +34,11 @@ function buildHtml({ port }) {
30
34
  .replaceAll('{{PORT}}', String(port));
31
35
  }
32
36
 
33
- export { buildHtml };
37
+ // The animated /wrapped year-in-review. Same compose-once pattern.
38
+ function buildWrappedHtml() {
39
+ return WRAPPED_TEMPLATE
40
+ .replace('{{STYLES}}', () => WRAPPED_STYLES)
41
+ .replace('{{SCRIPT}}', () => WRAPPED_SCRIPT);
42
+ }
43
+
44
+ export { buildHtml, buildWrappedHtml };
@@ -8,7 +8,7 @@ import { readAggregate } from '../scanner.js';
8
8
  import { generateInsights } from '../insights.js';
9
9
  import { badgeSvg } from '../badge.js';
10
10
  import { renderCard } from '../card.js';
11
- import { snapshot, windowedAggregate, aggregateToCsv } from './api.js';
11
+ import { snapshot, windowedAggregate, aggregateToCsv, wrappedData } from './api.js';
12
12
 
13
13
  export const JSON_HEADERS = {
14
14
  'content-type': 'application/json',
@@ -22,6 +22,11 @@ ROUTES.set('GET /api/state', (req, res) => {
22
22
  res.end(JSON.stringify(snapshot()));
23
23
  });
24
24
 
25
+ ROUTES.set('GET /api/wrapped', (req, res) => {
26
+ res.writeHead(200, JSON_HEADERS);
27
+ res.end(JSON.stringify(wrappedData()));
28
+ });
29
+
25
30
  ROUTES.set('GET /api/aggregate', (req, res, { query }) => {
26
31
  const range = query.range || '90d';
27
32
  const agg = readAggregate();
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.10.0';
14
+ const BAKED = '0.11.1';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {