Skip to content

Commit b421d04

Browse files
HduSyclaude
andcommitted
feat: full-screen confetti when week/month crosses a 100M-token milestone
Celebrate every new 100M tokens of usage with a full-screen, click-through fireworks overlay (like a Product Hunt launch). - Detection watches week ∪ month floors of ⌊total/100M⌋ (day is implied by month, except a calendar week can straddle a month boundary, so both are kept). Baselined on first observation so launch never fires retroactively; a single chunk advancing both floors fires once. - Overlay is a non-activating NSPanel on the primary monitor: transparent, mouse-event-ignoring (clicks pass through), focus-stealing-free, and able to float over apps in native fullscreen (MoveToActiveSpace | FullScreenAuxiliary). It auto-hides after the animation; an active flag prevents overlapping celebrations. - Animation (public/confetti.html) is a dependency-free canvas: a staggered series of confetti bursts at random positions around the screen centre. - Bump version to 0.1.16. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a1eaf6d commit b421d04

5 files changed

Lines changed: 308 additions & 4 deletions

File tree

public/confetti.html

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Tokenscope Celebration</title>
7+
<style>
8+
html,
9+
body {
10+
margin: 0;
11+
width: 100%;
12+
height: 100%;
13+
overflow: hidden;
14+
background: transparent;
15+
/* the window is click-through at the OS level too, but be safe */
16+
pointer-events: none;
17+
user-select: none;
18+
}
19+
#c {
20+
display: block;
21+
width: 100vw;
22+
height: 100vh;
23+
}
24+
</style>
25+
</head>
26+
<body>
27+
<canvas id="c"></canvas>
28+
<script>
29+
(function () {
30+
const cv = document.getElementById("c");
31+
const ctx = cv.getContext("2d");
32+
let W = 0,
33+
H = 0,
34+
dpr = 1;
35+
36+
function resize() {
37+
dpr = window.devicePixelRatio || 1;
38+
W = window.innerWidth;
39+
H = window.innerHeight;
40+
cv.width = Math.round(W * dpr);
41+
cv.height = Math.round(H * dpr);
42+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
43+
}
44+
resize();
45+
window.addEventListener("resize", resize);
46+
47+
// Festive, Product-Hunt-style multi-colour palette.
48+
const COLORS = [
49+
"#ff5e5b", "#ffd23f", "#1f9d63", "#34c27e", "#3b82f6",
50+
"#a855f7", "#ff8c42", "#ec4899", "#00c2ff", "#ffffff",
51+
];
52+
53+
let parts = [];
54+
let raf = null;
55+
let stopAt = 0;
56+
let timers = [];
57+
58+
const rand = (a, b) => a + Math.random() * (b - a);
59+
60+
// Spawn `count` confetti from (x,y) in a radial spray of strength `power`,
61+
// with a slight upward bias (`upBias`) so particles arc up then rain down.
62+
function spray(x, y, count, power, upBias) {
63+
for (let i = 0; i < count; i++) {
64+
const ang = rand(0, Math.PI * 2);
65+
const sp = rand(power * 0.35, power);
66+
parts.push({
67+
x, y,
68+
vx: Math.cos(ang) * sp,
69+
vy: Math.sin(ang) * sp - rand(upBias * 0.4, upBias),
70+
g: rand(0.12, 0.22),
71+
size: rand(6, 12),
72+
color: COLORS[(Math.random() * COLORS.length) | 0],
73+
rot: rand(0, Math.PI * 2),
74+
vr: rand(-0.3, 0.3),
75+
shape: Math.random() < 0.5 ? "rect" : "circ",
76+
life: 1,
77+
decay: rand(0.005, 0.012),
78+
sway: rand(0.4, 1.4),
79+
swayP: rand(0, Math.PI * 2),
80+
});
81+
}
82+
}
83+
84+
// A firework burst at a random position around the centre of the screen.
85+
function firework() {
86+
const n = rand(65, 95) | 0;
87+
spray(W * rand(0.2, 0.8), H * rand(0.22, 0.66), n, rand(11, 15), 2);
88+
}
89+
90+
function tick() {
91+
ctx.clearRect(0, 0, W, H);
92+
for (let i = parts.length - 1; i >= 0; i--) {
93+
const p = parts[i];
94+
p.vy += p.g;
95+
p.vx *= 0.99;
96+
p.swayP += 0.08;
97+
p.x += p.vx + Math.sin(p.swayP) * p.sway * 0.3;
98+
p.y += p.vy;
99+
p.rot += p.vr;
100+
p.life -= p.decay;
101+
if (p.life <= 0 || p.y > H + 40) {
102+
parts.splice(i, 1);
103+
continue;
104+
}
105+
ctx.save();
106+
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
107+
ctx.translate(p.x, p.y);
108+
ctx.rotate(p.rot);
109+
ctx.fillStyle = p.color;
110+
if (p.shape === "rect") {
111+
ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size * 0.66);
112+
} else {
113+
ctx.beginPath();
114+
ctx.arc(0, 0, p.size / 2, 0, Math.PI * 2);
115+
ctx.fill();
116+
}
117+
ctx.restore();
118+
}
119+
if (parts.length > 0 || performance.now() < stopAt) {
120+
raf = requestAnimationFrame(tick);
121+
} else {
122+
raf = null;
123+
ctx.clearRect(0, 0, W, H);
124+
}
125+
}
126+
127+
// (Re)start a celebration. Safe to call repeatedly; the backend triggers
128+
// this via eval, and we also auto-run it on first load.
129+
window.__burst = function () {
130+
resize();
131+
timers.forEach(clearTimeout);
132+
timers = [];
133+
// A staggered series of bursts at random positions around the centre.
134+
firework();
135+
[140, 340, 580, 850, 1150, 1500, 1900].forEach((t) =>
136+
timers.push(setTimeout(firework, t))
137+
);
138+
// Stop emitting after ~2.2s; existing confetti keeps falling/fading.
139+
stopAt = performance.now() + 2200;
140+
if (!raf) raf = requestAnimationFrame(tick);
141+
};
142+
143+
window.addEventListener("load", () => window.__burst());
144+
})();
145+
</script>
146+
</body>
147+
</html>

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tokenscope"
3-
version = "0.1.15"
3+
version = "0.1.16"
44
description = "Claude CLI token usage dashboard"
55
authors = ["you"]
66
edition = "2021"

src-tauri/src/lib.rs

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ mod pricing;
55
mod store;
66

77
use model::Dashboard;
8-
use std::sync::atomic::{AtomicI64, Ordering};
8+
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
99
use std::sync::Arc;
1010
use std::time::{SystemTime, UNIX_EPOCH};
1111
use tauri::{
@@ -41,9 +41,159 @@ fn refresh(app: &tauri::AppHandle) {
4141
if let Some(tray) = app.tray_by_id("main") {
4242
let _ = tray.set_title(Some(fmt_tokens_m(dash.today_tokens)));
4343
}
44+
check_milestones(app, &dash);
4445
let _ = app.emit("dashboard-updated", &dash);
4546
}
4647

48+
/// 100M-token celebration state. `floors` holds the last-seen ⌊total/100M⌋ for
49+
/// (week, month); `None` until the first observation baselines it (so we never
50+
/// fire retroactively for usage that already happened before launch). `active`
51+
/// guards against overlapping celebrations.
52+
struct Celebration {
53+
floors: std::sync::Mutex<Option<(i64, i64)>>,
54+
active: AtomicBool,
55+
}
56+
57+
/// Fire one full-screen celebration whenever the week OR month total crosses a
58+
/// new 100M-token boundary. We watch only week ∪ month, not day: today is always
59+
/// within both the current week and month, so a day crossing is already implied
60+
/// by the month — but a calendar week can straddle a month boundary, so early in
61+
/// a month the week total can lead the (freshly reset) month, hence both.
62+
/// Dedup is inherent: a single chunk of usage advancing both floors at once
63+
/// still calls celebrate() once.
64+
fn check_milestones(app: &tauri::AppHandle, dash: &Dashboard) {
65+
let Some(state) = app.try_state::<Celebration>() else {
66+
return;
67+
};
68+
// total_tokens is already in millions, so a 100M milestone is total / 100.
69+
let wf = (dash.week.metrics.total_tokens / 100.0).floor() as i64;
70+
let mf = (dash.month.metrics.total_tokens / 100.0).floor() as i64;
71+
let mut g = state.floors.lock().unwrap();
72+
let fire = match *g {
73+
None => false, // first observation: baseline only, never celebrate
74+
Some((pw, pm)) => wf > pw || mf > pm,
75+
};
76+
*g = Some((wf, mf)); // always update (also walks the baseline back down on a period reset)
77+
drop(g);
78+
if fire {
79+
celebrate(app);
80+
}
81+
}
82+
83+
/// Trigger the celebration overlay. Window/panel work must run on the main
84+
/// thread (refresh() runs on a background thread), so hop there.
85+
fn celebrate(app: &tauri::AppHandle) {
86+
let handle = app.clone();
87+
let _ = app.run_on_main_thread(move || show_celebration(&handle));
88+
}
89+
90+
/// Show (or reuse) a full-screen, click-through, non-activating overlay on the
91+
/// primary monitor and run the confetti animation, then hide it after it plays.
92+
/// Must be called on the main thread.
93+
fn show_celebration(app: &tauri::AppHandle) {
94+
let Some(state) = app.try_state::<Celebration>() else {
95+
return;
96+
};
97+
// Skip if a celebration is already playing.
98+
if state.active.swap(true, Ordering::SeqCst) {
99+
return;
100+
}
101+
102+
let (pos, size) = match app.primary_monitor() {
103+
Ok(Some(m)) => (*m.position(), *m.size()),
104+
_ => {
105+
state.active.store(false, Ordering::SeqCst);
106+
return;
107+
}
108+
};
109+
110+
let existed = app.get_webview_window("confetti").is_some();
111+
let win = match app.get_webview_window("confetti") {
112+
Some(w) => w,
113+
None => {
114+
match tauri::WebviewWindowBuilder::new(
115+
app,
116+
"confetti",
117+
tauri::WebviewUrl::App("confetti.html".into()),
118+
)
119+
.title("Tokenscope Celebration")
120+
.inner_size(size.width as f64, size.height as f64)
121+
.decorations(false)
122+
.transparent(true)
123+
.shadow(false)
124+
.always_on_top(true)
125+
.skip_taskbar(true)
126+
.focused(false)
127+
.resizable(false)
128+
.visible(false)
129+
.build()
130+
{
131+
Ok(w) => w,
132+
Err(_) => {
133+
state.active.store(false, Ordering::SeqCst);
134+
return;
135+
}
136+
}
137+
}
138+
};
139+
140+
// Cover the whole primary monitor and let clicks pass through to the apps
141+
// beneath — the celebration must never interrupt what the user is doing.
142+
let _ = win.set_position(pos);
143+
let _ = win.set_size(size);
144+
let _ = win.set_ignore_cursor_events(true);
145+
146+
#[cfg(target_os = "macos")]
147+
{
148+
use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior;
149+
#[allow(non_upper_case_globals)]
150+
const NS_NONACTIVATING_PANEL: i32 = 1 << 7;
151+
152+
// Convert to a non-activating panel once, so it can float over apps in
153+
// native fullscreen without stealing focus (same approach as the main
154+
// popover). On reuse the window is already a panel.
155+
if !existed {
156+
if let Ok(panel) = win.to_panel() {
157+
panel.set_level(25); // NSMainMenuWindowLevel (24) + 1
158+
panel.set_style_mask(NS_NONACTIVATING_PANEL);
159+
panel.set_collection_behaviour(
160+
NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace
161+
| NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
162+
);
163+
}
164+
}
165+
let _ = win.eval("window.__burst&&window.__burst()");
166+
if let Ok(panel) = app.get_webview_panel("confetti") {
167+
panel.show();
168+
}
169+
}
170+
#[cfg(not(target_os = "macos"))]
171+
{
172+
let _ = win.eval("window.__burst&&window.__burst()");
173+
let _ = win.show();
174+
}
175+
176+
// Hide once the animation has played out (emission ~2.3s + fall/fade).
177+
let app2 = app.clone();
178+
std::thread::spawn(move || {
179+
std::thread::sleep(Duration::from_millis(4200));
180+
let app3 = app2.clone();
181+
let _ = app2.run_on_main_thread(move || {
182+
#[cfg(target_os = "macos")]
183+
if let Ok(panel) = app3.get_webview_panel("confetti") {
184+
panel.order_out(None);
185+
}
186+
#[cfg(not(target_os = "macos"))]
187+
if let Some(w) = app3.get_webview_window("confetti") {
188+
let _ = w.hide();
189+
}
190+
if let Some(st) = app3.try_state::<Celebration>() {
191+
st.active.store(false, Ordering::SeqCst);
192+
}
193+
});
194+
});
195+
}
196+
47197
/// Last tray-icon rectangle (physical px: x, y, width, height), captured on tray
48198
/// click. Used to anchor the panel like tauri-plugin-positioner's
49199
/// TrayBottomCenter — but we can't use the positioner itself on a swizzled
@@ -193,6 +343,7 @@ fn get_dashboard(app: tauri::AppHandle) -> Dashboard {
193343
if let Some(tray) = app.tray_by_id("main") {
194344
let _ = tray.set_title(Some(fmt_tokens_m(dash.today_tokens)));
195345
}
346+
check_milestones(&app, &dash);
196347
dash
197348
}
198349

@@ -253,6 +404,12 @@ pub fn run() {
253404
#[cfg(target_os = "macos")]
254405
app.manage(TrayAnchor(std::sync::Mutex::new(None)));
255406

407+
// 100M-token celebration tracking (baselined on first observation).
408+
app.manage(Celebration {
409+
floors: std::sync::Mutex::new(None),
410+
active: AtomicBool::new(false),
411+
});
412+
256413
// Launch at login (idempotent — safe to call every start).
257414
let _ = app.autolaunch().enable();
258415

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Tokenscope",
4-
"version": "0.1.15",
4+
"version": "0.1.16",
55
"identifier": "com.tokenscope.app",
66
"build": {
77
"beforeDevCommand": "pnpm dev",

0 commit comments

Comments
 (0)