@ywkrlerche/richardwho 1.0.28 → 1.0.29

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/app.js CHANGED
@@ -287,6 +287,7 @@
287
287
  els.loader.hidden = true;
288
288
  hookFrameKeys();
289
289
  try { els.frame.contentWindow.focus(); } catch {}
290
+ stats.startPlay(game);
290
291
  }
291
292
 
292
293
  // Saved copies of recently played games (browser Cache Storage), so replays start instantly.
@@ -358,6 +359,7 @@
358
359
  }
359
360
 
360
361
  function stopGame() {
362
+ stats.stopPlay();
361
363
  if (!currentLoad) return;
362
364
  currentLoad.controller.abort();
363
365
  currentLoad = null;
@@ -396,12 +398,14 @@
396
398
  document.body.classList.remove("playing");
397
399
  document.title = tabTitle();
398
400
  const h = location.hash;
399
- const view = h === "#games" || h === "#recent" ? "games" : h === "#settings" ? "settings" : h === "#chat" ? "chat" : "home";
401
+ const view = h === "#games" || h === "#recent" ? "games" : h === "#settings" ? "settings" : h === "#chat" ? "chat" : h === "#stats" ? "stats" : "home";
400
402
  els.homeView.hidden = view !== "home";
401
403
  els.gamesView.hidden = view !== "games";
402
404
  $("view-settings").hidden = view !== "settings";
403
405
  $("view-chat").hidden = view !== "chat";
406
+ $("view-stats").hidden = view !== "stats";
404
407
  if (view === "settings") { renderSettings(); renderAdmin(); }
408
+ if (view === "stats") renderStats();
405
409
  if (view === "chat") openChat();
406
410
  track(view[0].toUpperCase() + view.slice(1));
407
411
  document.querySelectorAll(".rail-btn[data-nav]").forEach((b) =>
@@ -462,6 +466,152 @@
462
466
  applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"));
463
467
  }
464
468
 
469
+ // ---------- Play stats + achievements (saved in this browser) ----------
470
+ // Time counts only while a game is open AND the tab is visible.
471
+ const STATS_KEY = "rug-stats";
472
+ const ACHIEVEMENTS = [
473
+ { id: "first", icon: "🎮", name: "First Steps", desc: "Play your first game", goal: 1, of: (s) => s.unique },
474
+ { id: "ten", icon: "🔟", name: "Warming Up", desc: "Play 10 different games", goal: 10, of: (s) => s.unique },
475
+ { id: "25", icon: "🧭", name: "Explorer", desc: "Play 25 different games", goal: 25, of: (s) => s.unique },
476
+ { id: "50", icon: "🗺️", name: "Adventurer", desc: "Play 50 different games", goal: 50, of: (s) => s.unique },
477
+ { id: "100", icon: "💯", name: "Collector", desc: "Play 100 different games", goal: 100, of: (s) => s.unique },
478
+ { id: "250", icon: "👑", name: "Game Royalty", desc: "Play 250 different games", goal: 250, of: (s) => s.unique },
479
+ { id: "hour", icon: "⏱️", name: "One More Game", desc: "Play for 1 hour in total", goal: 60, of: (s) => s.minutes, unit: "min" },
480
+ { id: "10h", icon: "🛋️", name: "Couch Legend", desc: "Play for 10 hours in total", goal: 600, of: (s) => s.minutes, unit: "min" },
481
+ { id: "hooked", icon: "🎣", name: "Hooked", desc: "Spend 30 minutes in one game", goal: 30, of: (s) => s.topMinutes, unit: "min" },
482
+ { id: "replay", icon: "🔁", name: "Comfort Game", desc: "Open the same game 10 times", goal: 10, of: (s) => s.topPlays },
483
+ { id: "racing", icon: "🏎️", name: "Speed Freak", desc: "Play 10 racing games", goal: 10, of: (s) => s.byTag.Racing || 0 },
484
+ { id: "racing-all", icon: "🏁", name: "Every Racetrack", desc: "Play every racing game", goal: () => tagTotal("Racing"), of: (s) => s.byTag.Racing || 0 },
485
+ { id: "horror", icon: "👻", name: "Brave Soul", desc: "Play 5 horror games", goal: 5, of: (s) => s.byTag.Horror || 0 },
486
+ { id: "rhythm", icon: "🎵", name: "Got Rhythm", desc: "Play 5 rhythm games", goal: 5, of: (s) => s.byTag.Rhythm || 0 },
487
+ { id: "papa", icon: "🍕", name: "Papa's Apprentice", desc: "Play every Papa's game", goal: () => papasTotal(), of: (s) => s.papas },
488
+ { id: "allcats", icon: "🌈", name: "Well-Rounded", desc: "Play a game from every category", goal: () => tagList().length, of: (s) => s.tags },
489
+ { id: "wheel", icon: "🎡", name: "Feeling Lucky", desc: "Play a game the wheel picked", goal: 1, of: (s) => s.wheelPlays || 0 },
490
+ { id: "spins", icon: "🌀", name: "Spin Doctor", desc: "Spin the wheel 25 times", goal: 25, of: (s) => s.wheelSpins || 0 },
491
+ { id: "chat", icon: "💬", name: "Chatterbox", desc: "Send 10 chat messages", goal: 10, of: (s) => s.chatSent || 0 },
492
+ { id: "night", icon: "🦉", name: "Night Owl", desc: "Play a game between midnight and 4 AM", goal: 1, of: (s) => (s.night ? 1 : 0) },
493
+ ];
494
+ const tagList = () => [...new Set(games.filter((g) => g.tag !== "Tools").map((g) => g.tag))];
495
+ const tagTotal = (tag) => games.filter((g) => g.tag === tag).length;
496
+ const isPapas = (g) => /^papa'?s /i.test(g.name);
497
+ const papasTotal = () => games.filter(isPapas).length;
498
+
499
+ const stats = {
500
+ data: null,
501
+ current: null, // { id, since } while a game is open and visible
502
+ load() {
503
+ if (!this.data) this.data = { games: {}, wheelSpins: 0, wheelPlays: 0, chatSent: 0, night: false, unlocked: {}, ...store.get(STATS_KEY, {}) };
504
+ return this.data;
505
+ },
506
+ save() { store.set(STATS_KEY, this.data); },
507
+ bump(key) { this.load()[key] = (this.data[key] || 0) + 1; this.save(); checkAchievements(); },
508
+ startPlay(game) {
509
+ this.stopPlay();
510
+ const d = this.load();
511
+ const rec = d.games[game.id] || (d.games[game.id] = { plays: 0, ms: 0 });
512
+ rec.plays++;
513
+ rec.last = Date.now();
514
+ const h = new Date().getHours();
515
+ if (h < 4) d.night = true;
516
+ this.save();
517
+ this.current = { id: game.id, since: document.hidden ? null : Date.now() };
518
+ checkAchievements();
519
+ },
520
+ // Add the time since the last check to the open game (called on stop, tab hide, and every 30s)
521
+ flush() {
522
+ const c = this.current;
523
+ if (!c || !c.since) return;
524
+ const rec = this.load().games[c.id];
525
+ if (rec) rec.ms += Math.min(Date.now() - c.since, 60 * 60 * 1000); // cap any single gap at an hour
526
+ c.since = document.hidden ? null : Date.now();
527
+ this.save();
528
+ },
529
+ stopPlay() { this.flush(); this.current = null; },
530
+ summary() {
531
+ const d = this.load();
532
+ const entries = Object.entries(d.games).filter(([id]) => byId.has(id));
533
+ const byTag = {};
534
+ let papas = 0, ms = 0, topMs = 0, topPlays = 0;
535
+ for (const [id, r] of entries) {
536
+ const g = byId.get(id);
537
+ byTag[g.tag] = (byTag[g.tag] || 0) + 1;
538
+ if (isPapas(g)) papas++;
539
+ ms += r.ms;
540
+ topMs = Math.max(topMs, r.ms);
541
+ topPlays = Math.max(topPlays, r.plays);
542
+ }
543
+ return {
544
+ ...d, unique: entries.length, plays: entries.reduce((a, [, r]) => a + r.plays, 0),
545
+ minutes: Math.floor(ms / 60000), topMinutes: Math.floor(topMs / 60000), topPlays, byTag, papas,
546
+ tags: tagList().filter((t) => byTag[t]).length,
547
+ top: entries.sort((a, b) => b[1].ms - a[1].ms || b[1].plays - a[1].plays).slice(0, 5),
548
+ };
549
+ },
550
+ };
551
+ document.addEventListener("visibilitychange", () => {
552
+ if (!stats.current) return;
553
+ if (document.hidden) stats.flush();
554
+ else stats.current.since = Date.now();
555
+ });
556
+ addEventListener("pagehide", () => stats.flush());
557
+ setInterval(() => { stats.flush(); checkAchievements(); }, 30000);
558
+
559
+ const goalOf = (a) => (typeof a.goal === "function" ? a.goal() : a.goal);
560
+ function checkAchievements() {
561
+ if (!games.length) return;
562
+ const s = stats.summary(), d = stats.data;
563
+ for (const a of ACHIEVEMENTS) {
564
+ if (d.unlocked[a.id]) continue;
565
+ const goal = goalOf(a);
566
+ if (goal > 0 && a.of(s) >= goal) {
567
+ d.unlocked[a.id] = Date.now();
568
+ stats.save();
569
+ toast(`${a.icon} Achievement unlocked: ${a.name}`);
570
+ }
571
+ }
572
+ if (!$("view-stats").hidden) renderStats();
573
+ }
574
+
575
+ let toastTimer = 0;
576
+ function toast(text) {
577
+ const t = $("toast");
578
+ t.textContent = text;
579
+ t.classList.add("show");
580
+ clearTimeout(toastTimer);
581
+ toastTimer = setTimeout(() => t.classList.remove("show"), 4000);
582
+ }
583
+
584
+ function fmtTime(min) {
585
+ if (min < 1) return "<1 min";
586
+ const h = Math.floor(min / 60), m = min % 60;
587
+ return h ? `${h}h ${m}m` : `${m}m`;
588
+ }
589
+ function renderStats() {
590
+ if (!games.length) return;
591
+ const s = stats.summary(), d = stats.data;
592
+ const favTag = Object.entries(s.byTag).sort((a, b) => b[1] - a[1])[0];
593
+ $("stat-unique").textContent = s.unique;
594
+ $("stat-plays").textContent = s.plays;
595
+ $("stat-time").textContent = s.plays ? fmtTime(s.minutes) : "0 min";
596
+ $("stat-fav").textContent = favTag ? `${TAG_EMOJI[favTag[0]] || ""} ${favTag[0]}` : "–";
597
+ $("stat-top").innerHTML = s.top.length
598
+ ? s.top.map(([id, r], i) => `<li><span>${i + 1}. ${escapeHtml(byId.get(id).name)}</span><span>${fmtTime(Math.floor(r.ms / 60000))} · ${r.plays} play${r.plays === 1 ? "" : "s"}</span></li>`).join("")
599
+ : `<li class="muted">Play some games and your favorites show up here.</li>`;
600
+ const done = ACHIEVEMENTS.filter((a) => d.unlocked[a.id]).length;
601
+ $("ach-count").textContent = `${done} of ${ACHIEVEMENTS.length} unlocked`;
602
+ $("achievements").innerHTML = ACHIEVEMENTS.map((a) => {
603
+ const goal = goalOf(a), have = Math.min(a.of(s), goal), got = !!d.unlocked[a.id];
604
+ const pct = goal ? Math.round((have / goal) * 100) : 0;
605
+ const prog = a.unit === "min" ? `${fmtTime(have)} / ${fmtTime(goal)}` : `${have} / ${goal}`;
606
+ return `<div class="ach${got ? " got" : ""}" title="${escapeHtml(a.desc)}">
607
+ <span class="ach-icon">${got ? a.icon : "🔒"}</span>
608
+ <div class="ach-body"><b>${escapeHtml(a.name)}</b><small>${escapeHtml(a.desc)}</small>
609
+ ${got ? `<small class="ach-done">Unlocked ${new Date(d.unlocked[a.id]).toLocaleDateString()}</small>`
610
+ : `<div class="ach-bar"><div style="width:${pct}%"></div></div><small>${prog}</small>`}
611
+ </div></div>`;
612
+ }).join("");
613
+ }
614
+
465
615
  // ---------- Settings (saved in this browser) ----------
466
616
  const SETTINGS_KEY = "rug-settings";
467
617
  const REAL_ICON = "data:image/svg+xml," + encodeURIComponent("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='black'/><text x='50' y='70' font-size='62' text-anchor='middle' fill='white' font-family='Arial' font-weight='800'>r</text></svg>");
@@ -556,6 +706,12 @@
556
706
  $("set-per-row").addEventListener("change", (e) => setPerRow(Number(e.target.value)));
557
707
  $("set-typing").addEventListener("change", (e) => { settings.typing = e.target.checked; saveSettings(); startTypingPlaceholder(); });
558
708
  $("set-boot").addEventListener("change", (e) => { settings.boot = e.target.checked; saveSettings(); });
709
+ $("set-reset-stats").addEventListener("click", () => {
710
+ if (!confirm("Reset your stats and achievements on this device?")) return;
711
+ stats.data = null;
712
+ store.set(STATS_KEY, {});
713
+ renderStats();
714
+ });
559
715
  $("set-clear-recent").addEventListener("click", () => {
560
716
  store.set(RECENT_KEY, []);
561
717
  renderRecent();
@@ -612,6 +768,7 @@
612
768
  ctx.stroke();
613
769
  }
614
770
  function spinWheel(pool) {
771
+ stats.bump("wheelSpins");
615
772
  const picks = [...pool].sort(() => Math.random() - 0.5).slice(0, Math.min(12, pool.length));
616
773
  wheel.items = picks.length >= 2 ? picks : [...picks, ...picks];
617
774
  const n = wheel.items.length, a = (Math.PI * 2) / n;
@@ -668,7 +825,7 @@
668
825
  function bindWheel() {
669
826
  $("wheel-skip").addEventListener("click", finishWheel);
670
827
  $("wheel-again").addEventListener("click", () => spinWheel(wheelPool));
671
- $("wheel-play").addEventListener("click", () => { const g = wheel.winner; closeWheel(); play(g.id); });
828
+ $("wheel-play").addEventListener("click", () => { const g = wheel.winner; closeWheel(); stats.bump("wheelPlays"); play(g.id); });
672
829
  $("wheel-close").addEventListener("click", closeWheel);
673
830
  $("wheel-wrap").addEventListener("click", (e) => { if (e.target.id === "wheel-wrap") closeWheel(); });
674
831
  }
@@ -939,6 +1096,7 @@
939
1096
  [`chat/last/${chat.uid}`]: TS,
940
1097
  });
941
1098
  $("chat-text").value = "";
1099
+ stats.bump("chatSent");
942
1100
  chatNote("");
943
1101
  } catch {
944
1102
  chatNote("Message blocked (filtered word, or sending too fast).", true);
@@ -1076,6 +1234,27 @@
1076
1234
  window.addEventListener("popstate", route);
1077
1235
  }
1078
1236
 
1237
+ // ---------- Logo hover: spotlight follows the mouse, logo tilts toward it ----------
1238
+ function bindWordmark() {
1239
+ const w = document.querySelector(".wordmark");
1240
+ if (!w || matchMedia("(hover: none), (prefers-reduced-motion: reduce)").matches) return;
1241
+ w.addEventListener("mousemove", (e) => {
1242
+ const r = w.getBoundingClientRect();
1243
+ const x = (e.clientX - r.left) / r.width, y = (e.clientY - r.top) / r.height;
1244
+ w.classList.add("hovering");
1245
+ w.style.setProperty("--mx", `${(x * 100).toFixed(1)}%`);
1246
+ w.style.setProperty("--my", `${(y * 100).toFixed(1)}%`);
1247
+ w.style.setProperty("--spot", `${Math.round(r.height * 1.1)}px`);
1248
+ w.style.setProperty("--ry", `${((x - 0.5) * 14).toFixed(2)}deg`);
1249
+ w.style.setProperty("--rx", `${((0.5 - y) * 12).toFixed(2)}deg`);
1250
+ w.style.setProperty("--sc", "1.03");
1251
+ });
1252
+ w.addEventListener("mouseleave", () => {
1253
+ w.classList.remove("hovering");
1254
+ ["--ry", "--rx", "--sc", "--spot"].forEach((p) => w.style.removeProperty(p));
1255
+ });
1256
+ }
1257
+
1079
1258
  // ---------- Home search: placeholder that types itself ----------
1080
1259
  let timer = 0; // shared, so restarting (from Settings) replaces the running animation
1081
1260
  function startTypingPlaceholder() {
@@ -1196,6 +1375,7 @@
1196
1375
  bind();
1197
1376
  bindSettings();
1198
1377
  bindWheel();
1378
+ bindWordmark();
1199
1379
  $("chat-form").addEventListener("submit", sendChat);
1200
1380
  bindAdmin();
1201
1381
  startAnalytics();
package/index.html CHANGED
@@ -49,6 +49,9 @@
49
49
  <button class="rail-btn" type="button" data-nav="chat" title="Chat" aria-label="Chat">
50
50
  <svg viewBox="0 0 24 24"><path d="M4 3h16a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H9l-5 4v-4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm3 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm5 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm5 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"/></svg>
51
51
  </button>
52
+ <button class="rail-btn" type="button" data-nav="stats" title="Stats &amp; achievements" aria-label="Stats and achievements">
53
+ <svg viewBox="0 0 24 24"><path d="M7 3h10v2h4v3a5 5 0 0 1-4.6 5A5 5 0 0 1 13 15.9V18h3v3H8v-3h3v-2.1A5 5 0 0 1 7.6 13 5 5 0 0 1 3 8V5h4V3Zm0 4H5v1a3 3 0 0 0 2 2.8V7Zm12 0h-2v3.8A3 3 0 0 0 19 8V7Z"/></svg>
54
+ </button>
52
55
  <button class="rail-btn" type="button" data-nav="recent" title="Recently played" aria-label="Recently played">
53
56
  <svg viewBox="0 0 24 24"><path d="M13 3a9 9 0 1 1-8.95 10h2.02A7 7 0 1 0 13 5a6.98 6.98 0 0 0-4.94 2.06L10 9H4V3l2.64 2.64A8.97 8.97 0 0 1 13 3Zm-1 5h2v4.6l3.2 1.9-1 1.7L12 13.7V8Z"/></svg>
54
57
  </button>
@@ -150,6 +153,32 @@
150
153
  </div>
151
154
  </section>
152
155
 
156
+ <!-- STATS & ACHIEVEMENTS -->
157
+ <section id="view-stats" class="view settings stats-page" hidden>
158
+ <header class="games-head"><div><h1>Stats</h1><p class="lede">Your play time and achievements. Saved on this device only.</p></div></header>
159
+
160
+ <div class="set-group">
161
+ <h2>Your stats</h2>
162
+ <div class="stat-tiles">
163
+ <div class="stat"><b id="stat-unique">0</b><span>games played</span></div>
164
+ <div class="stat"><b id="stat-plays">0</b><span>times played</span></div>
165
+ <div class="stat"><b id="stat-time">–</b><span>time played</span></div>
166
+ <div class="stat"><b id="stat-fav">–</b><span>favorite category</span></div>
167
+ </div>
168
+ <h3 class="set-sub">Most played</h3>
169
+ <ol class="stat-top" id="stat-top"></ol>
170
+ </div>
171
+
172
+ <div class="set-group">
173
+ <h2>Achievements <small class="ach-count" id="ach-count"></small></h2>
174
+ <div class="achievements" id="achievements"></div>
175
+ </div>
176
+
177
+ <div class="set-group">
178
+ <div class="set-row"><span>Start over</span><button class="pill-btn" type="button" id="set-reset-stats">Reset stats &amp; achievements</button></div>
179
+ </div>
180
+ </section>
181
+
153
182
  <!-- SETTINGS -->
154
183
  <section id="view-settings" class="view settings" hidden>
155
184
  <header class="games-head"><div><h1>Settings</h1><p class="lede">Saved on this device only.</p></div></header>
@@ -208,8 +237,7 @@
208
237
 
209
238
  <div class="set-group">
210
239
  <h2>Your data</h2>
211
- <div class="set-row"><span>Recently played</span><button class="pill-btn" type="button" id="set-clear-recent">Clear</button></div>
212
- <div class="set-row"><span>All settings</span><button class="pill-btn danger" type="button" id="set-reset">Reset to default</button></div>
240
+ <div class="set-row"><span>Recently played</span><button class="pill-btn" type="button" id="set-clear-recent">Clear</button></div> <div class="set-row"><span>All settings</span><button class="pill-btn danger" type="button" id="set-reset">Reset to default</button></div>
213
241
  </div>
214
242
  </section>
215
243
  </main>
@@ -263,6 +291,8 @@
263
291
  </div>
264
292
  </section>
265
293
 
294
+ <div class="toast" id="toast" role="status" aria-live="polite"></div>
295
+
266
296
  <footer class="corner-links">
267
297
  <span><kbd>`</kbd> or <kbd>]</kbd> panic</span>
268
298
  <span id="footer-count"></span>
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ywkrlerche/richardwho",
3
- "version": "1.0.28",
3
+ "version": "1.0.29",
4
4
  "description": "Richards Unblocked Games",
5
5
  "license": "UNLICENSED",
6
6
  "files": [
package/style.css CHANGED
@@ -11,8 +11,10 @@
11
11
  --soft-2: rgba(255, 255, 255, .1);
12
12
  --accent: #3b82f6;
13
13
  --danger: #ef4444;
14
- --glow-blue: rgba(30, 64, 175, .55);
15
- --glow-orange: rgba(194, 92, 30, .35);
14
+ --glow-blue: rgba(37, 78, 205, .55);
15
+ --glow-cyan: rgba(14, 165, 233, .30);
16
+ --glow-violet: rgba(99, 76, 230, .32);
17
+ --shine: #93c5fd;
16
18
  --rail-w: 58px;
17
19
  --per-row: 6;
18
20
  --font: "Inter", "Segoe UI Variable Display", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
@@ -29,7 +31,9 @@
29
31
  --soft: rgba(0, 0, 0, .05);
30
32
  --soft-2: rgba(0, 0, 0, .09);
31
33
  --glow-blue: rgba(59, 130, 246, .28);
32
- --glow-orange: rgba(249, 115, 22, .2);
34
+ --glow-cyan: rgba(14, 165, 233, .18);
35
+ --glow-violet: rgba(124, 58, 237, .14);
36
+ --shine: #2563eb;
33
37
  color-scheme: light;
34
38
  }
35
39
 
@@ -63,8 +67,8 @@ kbd { font: 600 11px ui-monospace, Consolas, monospace; padding: 1px 6px; border
63
67
  .aurora { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
64
68
  .aurora span { position: absolute; border-radius: 50%; filter: blur(90px); }
65
69
  .aurora .a1 { width: 70vw; height: 26vh; left: 22vw; top: 8vh; background: var(--glow-blue); transform: rotate(-12deg); animation: drift 18s ease-in-out infinite alternate; }
66
- .aurora .a2 { width: 34vw; height: 20vh; right: -6vw; top: -6vh; background: var(--glow-orange); animation: drift 22s ease-in-out infinite alternate-reverse; }
67
- .aurora .a3 { width: 40vw; height: 18vh; left: 8vw; top: 26vh; background: color-mix(in srgb, var(--glow-blue) 55%, transparent); animation: drift 26s ease-in-out infinite alternate; }
70
+ .aurora .a2 { width: 36vw; height: 22vh; right: -4vw; top: -4vh; background: var(--glow-cyan); animation: drift 22s ease-in-out infinite alternate-reverse; }
71
+ .aurora .a3 { width: 42vw; height: 20vh; left: 6vw; top: 28vh; background: var(--glow-violet); animation: drift 26s ease-in-out infinite alternate; }
68
72
  @keyframes drift { from { translate: 0 0; } to { translate: 4vw 3vh; } }
69
73
  body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
70
74
 
@@ -101,11 +105,36 @@ body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
101
105
 
102
106
  /* ---------- Home ---------- */
103
107
  .home { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding-bottom: 12vh; }
108
+ /* Animated logo: fades up on load, a light sweeps across it, and a soft blue glow breathes behind it */
109
+ .wordmark, .boot-word {
110
+ background: linear-gradient(100deg, var(--text) 0%, var(--text) 42%, var(--shine) 50%, var(--text) 58%, var(--text) 100%);
111
+ background-size: 260% 100%;
112
+ -webkit-background-clip: text; background-clip: text; color: transparent;
113
+ animation: shine 5.5s ease-in-out infinite, glow 4s ease-in-out infinite alternate;
114
+ }
104
115
  .wordmark {
105
116
  margin: 0 0 30px; font-size: clamp(64px, 11vw, 150px); font-weight: 800; letter-spacing: -.055em; line-height: .9;
106
- background: linear-gradient(180deg, var(--text) 55%, color-mix(in srgb, var(--text) 70%, transparent));
107
- -webkit-background-clip: text; background-clip: text; color: transparent;
117
+ padding: 0 .04em .08em; /* room for the glow and descenders */
118
+ cursor: default; user-select: none;
119
+ /* layer 1: hover spotlight that follows the mouse (--mx/--my set in app.js); layer 2: the sweeping shine */
120
+ background-image:
121
+ radial-gradient(circle var(--spot, 0px) at var(--mx, 50%) var(--my, 50%), var(--shine), transparent 70%),
122
+ linear-gradient(100deg, var(--text) 0%, var(--text) 42%, var(--shine) 50%, var(--text) 58%, var(--text) 100%);
123
+ background-size: 100% 100%, 260% 100%;
124
+ background-repeat: no-repeat, repeat; /* the shine layer must repeat or the word loses its fill */
125
+ transform: perspective(700px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(var(--sc, 1));
126
+ transition: transform .35s cubic-bezier(.2, .8, .2, 1), --spot .3s;
127
+ animation: rise .9s cubic-bezier(.2, .8, .2, 1) both, shine2 5.5s ease-in-out 1s infinite, glow 4s ease-in-out infinite alternate;
128
+ }
129
+ .wordmark.hovering { transition: transform .12s ease-out; }
130
+ @property --spot { syntax: "<length>"; inherits: false; initial-value: 0px; }
131
+ @keyframes shine { 0% { background-position: 100% 0; } 55%, 100% { background-position: -60% 0; } }
132
+ @keyframes shine2 { 0% { background-position: 0 0, 100% 0; } 55%, 100% { background-position: 0 0, -60% 0; } }
133
+ @keyframes glow {
134
+ from { filter: drop-shadow(0 0 18px color-mix(in srgb, var(--shine) 20%, transparent)); }
135
+ to { filter: drop-shadow(0 0 42px color-mix(in srgb, var(--shine) 45%, transparent)); }
108
136
  }
137
+ @keyframes rise { from { opacity: 0; translate: 0 14px; letter-spacing: -.02em; filter: blur(6px); } to { opacity: 1; translate: 0 0; } }
109
138
  .home-search {
110
139
  position: relative; width: min(640px, 100%); height: 64px; display: flex; align-items: center; gap: 14px; padding: 0 22px;
111
140
  background: color-mix(in srgb, var(--panel) 88%, transparent); border: 1px solid var(--line-2); border-radius: 4px;
@@ -246,6 +275,36 @@ body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
246
275
  .admin-id { font: 12px ui-monospace, Consolas, monospace; padding: 4px 8px; border: 1px solid var(--line-2); border-radius: 4px; user-select: all; max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
247
276
  @media (max-width: 720px) { .chat-name { width: 90px; } }
248
277
 
278
+ /* ---------- Stats + achievements ---------- */
279
+ .stat-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 12px 0 14px; }
280
+ .stat { border: 1px solid var(--line); border-radius: 6px; padding: 12px; background: var(--soft); }
281
+ .stat b { display: block; font-size: 22px; font-weight: 800; letter-spacing: -.02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
282
+ .stat span { color: var(--muted); font-size: 12px; }
283
+ .set-sub { margin: 4px 0 6px; font-size: 13px; color: var(--muted); font-weight: 600; }
284
+ .stat-top { list-style: none; margin: 0; padding: 0; }
285
+ .stat-top li { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-top: 1px solid var(--line); font-size: 14px; }
286
+ .stat-top li span:last-child { color: var(--muted); font-size: 13px; white-space: nowrap; }
287
+ .stat-top .muted { color: var(--muted); }
288
+ .ach-count { color: var(--muted); font-size: 12px; font-weight: 500; margin-left: 8px; }
289
+ .achievements { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; margin-top: 12px; }
290
+ .ach { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); opacity: .7; }
291
+ .ach.got { opacity: 1; border-color: color-mix(in srgb, #facc15 55%, var(--line)); background: color-mix(in srgb, #facc15 8%, var(--soft)); }
292
+ .ach-icon { font-size: 22px; line-height: 1; }
293
+ .ach-body { display: grid; gap: 2px; min-width: 0; }
294
+ .ach-body b { font-size: 13.5px; }
295
+ .ach-body small { color: var(--muted); font-size: 11.5px; }
296
+ .ach-done { color: #ca8a04 !important; }
297
+ .ach-bar { height: 4px; border-radius: 4px; background: var(--line); overflow: hidden; margin-top: 4px; }
298
+ .ach-bar div { height: 100%; background: var(--text); }
299
+ @media (max-width: 720px) { .stat-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
300
+
301
+ .toast {
302
+ position: fixed; left: 50%; bottom: 22px; z-index: 90; transform: translate(-50%, 20px); opacity: 0; pointer-events: none;
303
+ padding: 12px 18px; border-radius: 8px; background: #111; color: #fff; border: 1px solid #facc15;
304
+ font-weight: 700; font-size: 14px; box-shadow: 0 12px 30px rgba(0,0,0,.5); transition: opacity .25s, transform .25s;
305
+ }
306
+ .toast.show { opacity: 1; transform: translate(-50%, 0); }
307
+
249
308
  /* ---------- Random game wheel ---------- */
250
309
  .wheel-wrap { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 16px; background: rgba(0,0,0,.7); backdrop-filter: blur(6px); }
251
310
  .wheel-card { position: relative; width: min(440px, 100%); text-align: center; background: var(--panel); border: 1px solid var(--line-2); border-radius: 8px; padding: 20px 20px 18px; box-shadow: 0 30px 80px rgba(0,0,0,.6); }
@@ -290,7 +349,10 @@ body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
290
349
  .corner-links { position: fixed; right: 14px; bottom: 12px; z-index: 5; display: flex; gap: 8px; }
291
350
  .corner-links > span { padding: 6px 10px; border: 1px solid var(--line); border-radius: 4px; background: color-mix(in srgb, var(--panel) 85%, transparent); font-size: 12px; color: var(--muted); }
292
351
  body.playing { overflow: hidden; }
293
- body:has(#view-games:not([hidden])) .corner-links { display: none; }
352
+ body:has(#view-games:not([hidden])) .corner-links,
353
+ body:has(#view-stats:not([hidden])) .corner-links,
354
+ body:has(#view-settings:not([hidden])) .corner-links,
355
+ body:has(#view-chat:not([hidden])) .corner-links { display: none; }
294
356
 
295
357
  /* ---------- Responsive ---------- */
296
358
  @media (max-width: 1100px) { .grid, .recent-row { grid-template-columns: repeat(min(var(--per-row), 4), minmax(0, 1fr)); } }
@@ -309,4 +371,4 @@ body:has(#view-games:not([hidden])) .corner-links { display: none; }
309
371
  .corner-links { display: none; }
310
372
  .clock { top: 8px; right: 8px; }
311
373
  }
312
- @media (prefers-reduced-motion: reduce) { .aurora span, .sweep::after { animation: none; } }
374
+ @media (prefers-reduced-motion: reduce) { .aurora span, .sweep::after, .wordmark, .boot-word { animation: none; } }