@ywkrlerche/richardwho 1.0.26 → 1.0.28

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.
Files changed (5) hide show
  1. package/app.js +712 -36
  2. package/config.js +20 -1
  3. package/index.html +124 -0
  4. package/package.json +1 -1
  5. package/style.css +88 -0
package/app.js CHANGED
@@ -4,8 +4,7 @@
4
4
  const CFG = window.RUG_CONFIG || {};
5
5
  const SITE_NAME = "Richards Unblocked Games";
6
6
  // The tab/history shows this instead of the real names (set in config.js)
7
- const TAB_TITLE = CFG.tabTitle || null;
8
- const tabTitle = (gameName) => TAB_TITLE || (gameName ? `${gameName} · ${SITE_NAME}` : SITE_NAME);
7
+ const tabTitle = (gameName) => CFG.tabTitle || (gameName ? `${gameName} · ${SITE_NAME}` : SITE_NAME);
9
8
  const RECENT_KEY = "rug-recent";
10
9
  const CLOCK_KEY = "rug-clock24";
11
10
  const THEME_KEY = "rug-theme";
@@ -223,6 +222,14 @@
223
222
  };
224
223
 
225
224
  try {
225
+ // Played before? Start straight from the browser's saved copy, no download
226
+ const cached = await gameCache.get(urls[0]);
227
+ if (cached && currentLoad?.id === game.id) {
228
+ els.barFill.style.width = "100%";
229
+ els.loaderStatus.textContent = "Starting (saved copy)…";
230
+ startGameHtml(game, await cached.text());
231
+ return;
232
+ }
226
233
  const pieces = await Promise.all(urls.map(async (url, i) => {
227
234
  // Try the main link, then backups. jsDelivr sometimes fails the first fetch of a file it
228
235
  // hasn't cached yet, and a download can drop mid-way, so a retry usually succeeds.
@@ -258,17 +265,9 @@
258
265
  }));
259
266
  if (currentLoad?.id !== game.id) return;
260
267
  els.loaderStatus.textContent = "Starting…";
261
- let html = await new Blob(pieces.flat()).text();
262
- if (currentLoad?.id !== game.id) return;
263
- if (game.src === "gn") html = stripTracking(html);
264
- html = guardHistory(html);
265
- const doc = freshFrame().contentDocument;
266
- doc.open();
267
- doc.write(html);
268
- doc.close();
269
- els.loader.hidden = true;
270
- hookFrameKeys();
271
- try { els.frame.contentWindow.focus(); } catch {}
268
+ const blob = new Blob(pieces.flat());
269
+ gameCache.put(urls[0], blob); // in the background; a full disk just means no saved copy
270
+ startGameHtml(game, await blob.text());
272
271
  } catch (err) {
273
272
  if (err.name === "AbortError") return;
274
273
  console.error(err);
@@ -277,6 +276,52 @@
277
276
  }
278
277
  }
279
278
 
279
+ function startGameHtml(game, html) {
280
+ if (currentLoad?.id !== game.id) return;
281
+ if (game.src === "gn") html = stripTracking(html);
282
+ html = guardHistory(html);
283
+ const doc = freshFrame().contentDocument;
284
+ doc.open();
285
+ doc.write(html);
286
+ doc.close();
287
+ els.loader.hidden = true;
288
+ hookFrameKeys();
289
+ try { els.frame.contentWindow.focus(); } catch {}
290
+ }
291
+
292
+ // Saved copies of recently played games (browser Cache Storage), so replays start instantly.
293
+ // Keyed by the game's first file URL, which includes the version tag, so new versions don't reuse old copies.
294
+ const gameCache = {
295
+ NAME: "rug-games-v1",
296
+ KEEP: 12,
297
+ async get(key) {
298
+ try {
299
+ if (!window.caches) return null;
300
+ const hit = await (await caches.open(this.NAME)).match(key);
301
+ if (hit) this.touch(key);
302
+ return hit || null;
303
+ } catch { return null; }
304
+ },
305
+ async put(key, blob) {
306
+ try {
307
+ if (!window.caches) return;
308
+ await (await caches.open(this.NAME)).put(key, new Response(blob, { headers: { "Content-Type": "text/html" } }));
309
+ await this.touch(key);
310
+ } catch { /* out of space or not allowed: just skip saving */ }
311
+ },
312
+ async touch(key) {
313
+ // most-recent-first list; drop anything past KEEP
314
+ const list = store.get("rug-cache-list", []).filter((k) => k !== key);
315
+ list.unshift(key);
316
+ const drop = list.splice(this.KEEP);
317
+ store.set("rug-cache-list", list);
318
+ if (drop.length) {
319
+ const c = await caches.open(this.NAME);
320
+ await Promise.all(drop.map((k) => c.delete(k)));
321
+ }
322
+ },
323
+ };
324
+
280
325
  // Backup sources for a jsDelivr GitHub link, in the order to try them:
281
326
  // the same link again, jsDelivr's second network, then GitHub's own file server.
282
327
  // The extra collection's files are also published as gn-math/html at the same commit.
@@ -304,11 +349,12 @@
304
349
  // or pushState() would move the whole tab (and kick the player out). Give games no-op versions first.
305
350
  const HISTORY_GUARD = "<script>(function(){try{var h=window.history,n=function(){};" +
306
351
  "h.back=h.forward=h.go=h.pushState=h.replaceState=n;}catch(e){}})();<\/script>";
352
+ // Insert only at the very top of the file (after an optional doctype and <html> tag). Searching for the
353
+ // first "<head>" anywhere is unsafe: many self-unpacking games keep their whole page inside a script string,
354
+ // and inserting there cuts that string in half.
307
355
  function guardHistory(html) {
308
- if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + HISTORY_GUARD);
309
- if (/<html[^>]*>/i.test(html)) return html.replace(/<html[^>]*>/i, (m) => m + HISTORY_GUARD);
310
- if (/^\s*<!doctype[^>]*>/i.test(html)) return html.replace(/^\s*<!doctype[^>]*>/i, (m) => m + HISTORY_GUARD);
311
- return HISTORY_GUARD + html;
356
+ const top = html.match(/^(?\s*(?:<!--[\s\S]*?-->\s*)*(?:<!doctype[^>]*>)?\s*(?:<html\b[^>]*>)?)/i)[1];
357
+ return top + HISTORY_GUARD + html.slice(top.length);
312
358
  }
313
359
 
314
360
  function stopGame() {
@@ -339,16 +385,25 @@
339
385
  els.player.hidden = false;
340
386
  document.body.classList.add("playing");
341
387
  addRecent(game.id);
342
- if (currentLoad?.id !== game.id) loadGame(game);
388
+ if (currentLoad?.id !== game.id) {
389
+ loadGame(game);
390
+ track(`Play: ${game.name}`, { name: "play_game", params: { game_name: game.name, game_category: game.tag } });
391
+ }
343
392
  return;
344
393
  }
345
394
  stopGame();
346
395
  els.player.hidden = true;
347
396
  document.body.classList.remove("playing");
348
397
  document.title = tabTitle();
349
- const view = location.hash === "#games" || location.hash === "#recent" ? "games" : "home";
398
+ const h = location.hash;
399
+ const view = h === "#games" || h === "#recent" ? "games" : h === "#settings" ? "settings" : h === "#chat" ? "chat" : "home";
350
400
  els.homeView.hidden = view !== "home";
351
401
  els.gamesView.hidden = view !== "games";
402
+ $("view-settings").hidden = view !== "settings";
403
+ $("view-chat").hidden = view !== "chat";
404
+ if (view === "settings") { renderSettings(); renderAdmin(); }
405
+ if (view === "chat") openChat();
406
+ track(view[0].toUpperCase() + view.slice(1));
352
407
  document.querySelectorAll(".rail-btn[data-nav]").forEach((b) =>
353
408
  b.classList.toggle("active", b.dataset.nav === (location.hash === "#recent" ? "recent" : view)));
354
409
  if (view === "games") renderRecent();
@@ -373,40 +428,523 @@
373
428
  try { window.top.location.replace(url); } catch { location.replace(url); }
374
429
  }
375
430
 
431
+ // The dice button opens the wheel (filtered to the current category on the games page)
376
432
  function randomGame() {
377
433
  const pool = !els.gamesView.hidden && activeTag !== "All" ? games.filter((g) => g.tag === activeTag) : games;
378
- if (pool.length) play(pool[Math.floor(Math.random() * pool.length)].id);
434
+ if (pool.length) openWheel(pool);
379
435
  }
380
436
 
437
+ let clockIs24 = false, clockTick = () => {};
438
+ function setClock24(on) { clockIs24 = on; store.set(CLOCK_KEY, on); clockTick(); }
381
439
  function startClock() {
382
- let is24 = store.get(CLOCK_KEY, false);
440
+ clockIs24 = store.get(CLOCK_KEY, false);
383
441
  const timeEl = $("clock-time"), metaEl = $("clock-meta");
384
- const tick = () => {
442
+ clockTick = () => {
385
443
  const d = new Date();
386
444
  let h = d.getHours();
387
445
  const m = String(d.getMinutes()).padStart(2, "0");
388
- if (is24) { timeEl.textContent = `${String(h).padStart(2, "0")}:${m}`; metaEl.textContent = "24H"; }
446
+ if (clockIs24) { timeEl.textContent = `${String(h).padStart(2, "0")}:${m}`; metaEl.textContent = "24H"; }
389
447
  else { const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12; timeEl.textContent = `${h}:${m}`; metaEl.textContent = ap; }
390
448
  };
391
- $("clock").addEventListener("click", () => { is24 = !is24; store.set(CLOCK_KEY, is24); tick(); });
392
- tick();
393
- setInterval(tick, 1000);
449
+ $("clock").addEventListener("click", () => setClock24(!clockIs24));
450
+ clockTick();
451
+ setInterval(clockTick, 1000);
394
452
  }
395
453
 
454
+ function applyTheme(t) {
455
+ document.documentElement.dataset.theme = t;
456
+ $("theme-btn").title = t === "dark" ? "Switch to light mode" : "Switch to dark mode";
457
+ try { localStorage.setItem(THEME_KEY, t); } catch {}
458
+ }
396
459
  function initTheme() {
397
- const btn = $("theme-btn");
398
- const apply = (t) => {
399
- document.documentElement.dataset.theme = t;
400
- btn.title = t === "dark" ? "Switch to light mode" : "Switch to dark mode";
460
+ applyTheme(document.documentElement.dataset.theme === "light" ? "light" : "dark");
461
+ $("theme-btn").addEventListener("click", () =>
462
+ applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"));
463
+ }
464
+
465
+ // ---------- Settings (saved in this browser) ----------
466
+ const SETTINGS_KEY = "rug-settings";
467
+ 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>");
468
+ const DISGUISES = [
469
+ { id: "classroom", label: "Google Classroom", title: "Google Classroom", icon: "https://ssl.gstatic.com/classroom/favicon.png" },
470
+ { id: "docs", label: "Google Docs", title: "Untitled document - Google Docs", icon: "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico" },
471
+ { id: "drive", label: "Google Drive", title: "My Drive - Google Drive", icon: "https://ssl.gstatic.com/images/branding/product/1x/drive_2020q4_32dp.png" },
472
+ { id: "gmail", label: "Gmail", title: "Inbox - Gmail", icon: "https://ssl.gstatic.com/images/branding/product/1x/gmail_2020q4_32dp.png" },
473
+ { id: "google", label: "Google", title: "Google", icon: "https://www.google.com/favicon.ico" },
474
+ { id: "canvas", label: "Canvas", title: "Dashboard", icon: "https://du11hjcvx0uqb.cloudfront.net/dist/images/favicon-e10d657a73.ico" },
475
+ { id: "off", label: "Off (real name)", title: null, icon: REAL_ICON },
476
+ ];
477
+ const DEFAULT_PANIC_KEYS = CFG.panicKeys || ["`", "]"];
478
+ const DEFAULT_PANIC_URL = CFG.panicUrl || "https://classroom.google.com/";
479
+ let settings = {};
480
+ let listeningForKey = false;
481
+
482
+ function loadSettings() {
483
+ settings = { disguise: "classroom", typing: true, boot: true, ...store.get(SETTINGS_KEY, {}) };
484
+ applySettings();
485
+ }
486
+ function saveSettings() {
487
+ const d = DISGUISES.find((x) => x.id === settings.disguise) || DISGUISES[0];
488
+ // tabTitle/tabIcon are copied in so index.html can apply the disguise before the page finishes loading
489
+ store.set(SETTINGS_KEY, { ...settings, tabTitle: d.title, tabIcon: d.icon });
490
+ applySettings();
491
+ }
492
+ function applySettings() {
493
+ const d = DISGUISES.find((x) => x.id === settings.disguise) || DISGUISES[0];
494
+ CFG.tabTitle = d.title;
495
+ $("tab-icon").href = d.icon;
496
+ CFG.panicUrl = settings.panicUrl || DEFAULT_PANIC_URL;
497
+ CFG.panicKeys = settings.panicKey ? [settings.panicKey] : DEFAULT_PANIC_KEYS;
498
+ document.title = tabTitle(els.player.hidden ? "" : els.playerTitle.textContent);
499
+ const keys = CFG.panicKeys.map((k) => (k === " " ? "Space" : k)).join(" or ");
500
+ $("panic-btn").title = `Panic (${keys})`;
501
+ document.querySelector(".corner-links span").innerHTML = `${CFG.panicKeys.map((k) => `<kbd>${escapeHtml(k === " " ? "Space" : k)}</kbd>`).join(" or ")} panic`;
502
+ }
503
+
504
+ function renderSettings() {
505
+ $("disguises").innerHTML = DISGUISES.map((d) => `<button class="disguise" type="button" data-disguise="${d.id}" aria-pressed="${settings.disguise === d.id}">
506
+ ${d.id === "off" ? `<span class="none-icon">🚫</span>` : `<img src="${d.icon}" alt="">`}<span>${escapeHtml(d.label)}</span></button>`).join("");
507
+ const preset = [...$("set-panic-url").options].some((o) => o.value === CFG.panicUrl);
508
+ $("set-panic-url").value = preset ? CFG.panicUrl : "custom";
509
+ $("set-panic-custom-row").hidden = preset;
510
+ $("set-panic-custom").value = preset ? "" : CFG.panicUrl;
511
+ $("set-panic-key").textContent = CFG.panicKeys.map((k) => (k === " " ? "Space" : k)).join(" or ");
512
+ $("set-theme").value = document.documentElement.dataset.theme;
513
+ $("set-clock").value = clockIs24 ? "24" : "12";
514
+ $("set-per-row").value = String(store.get(PER_ROW_KEY, 6));
515
+ $("set-typing").checked = settings.typing !== false;
516
+ $("set-boot").checked = settings.boot !== false;
517
+ }
518
+
519
+ function bindSettings() {
520
+ $("disguises").addEventListener("click", (e) => {
521
+ const b = e.target.closest("[data-disguise]");
522
+ if (!b) return;
523
+ settings.disguise = b.dataset.disguise;
524
+ saveSettings();
525
+ renderSettings();
526
+ });
527
+ $("set-panic-url").addEventListener("change", (e) => {
528
+ const custom = e.target.value === "custom";
529
+ $("set-panic-custom-row").hidden = !custom;
530
+ if (!custom) { settings.panicUrl = e.target.value; saveSettings(); } else $("set-panic-custom").focus();
531
+ });
532
+ $("set-panic-custom").addEventListener("change", (e) => {
533
+ let url = e.target.value.trim();
534
+ if (url && !/^https?:\/\//i.test(url)) url = "https://" + url;
535
+ if (url) { settings.panicUrl = url; e.target.value = url; saveSettings(); }
536
+ });
537
+ const keyBtn = $("set-panic-key");
538
+ keyBtn.addEventListener("click", () => {
539
+ listeningForKey = true;
540
+ keyBtn.classList.add("listening");
541
+ keyBtn.textContent = "Press a key…";
542
+ $("set-panic-key-hint").textContent = "Esc to cancel";
543
+ });
544
+ document.addEventListener("keydown", (e) => {
545
+ if (!listeningForKey) return;
546
+ e.preventDefault();
547
+ e.stopImmediatePropagation();
548
+ listeningForKey = false;
549
+ keyBtn.classList.remove("listening");
550
+ $("set-panic-key-hint").textContent = "Click, then press any key";
551
+ if (e.key !== "Escape" && e.key.length === 1) { settings.panicKey = e.key; saveSettings(); }
552
+ renderSettings();
553
+ }, true);
554
+ $("set-theme").addEventListener("change", (e) => applyTheme(e.target.value));
555
+ $("set-clock").addEventListener("change", (e) => setClock24(e.target.value === "24"));
556
+ $("set-per-row").addEventListener("change", (e) => setPerRow(Number(e.target.value)));
557
+ $("set-typing").addEventListener("change", (e) => { settings.typing = e.target.checked; saveSettings(); startTypingPlaceholder(); });
558
+ $("set-boot").addEventListener("change", (e) => { settings.boot = e.target.checked; saveSettings(); });
559
+ $("set-clear-recent").addEventListener("click", () => {
560
+ store.set(RECENT_KEY, []);
561
+ renderRecent();
562
+ $("set-clear-recent").textContent = "Cleared ✓";
563
+ setTimeout(() => { $("set-clear-recent").textContent = "Clear"; }, 1500);
564
+ });
565
+ $("set-reset").addEventListener("click", () => {
566
+ settings = { disguise: "classroom", typing: true, boot: true };
567
+ saveSettings();
568
+ applyTheme("dark");
569
+ setClock24(false);
570
+ setPerRow(6);
571
+ startTypingPlaceholder();
572
+ renderSettings();
573
+ });
574
+ }
575
+
576
+ // ---------- Random game wheel ----------
577
+ const WHEEL_COLORS = ["#2563eb", "#7c3aed", "#db2777", "#ea580c", "#16a34a", "#0891b2"];
578
+ const wheel = { items: [], rot: 0, from: 0, target: 0, start: 0, dur: 5200, raf: 0, winner: null, spinning: false };
579
+ function drawWheel() {
580
+ const c = $("wheel"), ctx = c.getContext("2d");
581
+ const R = c.width / 2, n = wheel.items.length, a = (Math.PI * 2) / n;
582
+ ctx.clearRect(0, 0, c.width, c.height);
583
+ for (let i = 0; i < n; i++) {
584
+ const s = wheel.rot + i * a;
585
+ ctx.beginPath();
586
+ ctx.moveTo(R, R);
587
+ ctx.arc(R, R, R - 6, s, s + a);
588
+ ctx.closePath();
589
+ ctx.fillStyle = WHEEL_COLORS[i % WHEEL_COLORS.length];
590
+ ctx.fill();
591
+ ctx.strokeStyle = "rgba(0,0,0,.35)";
592
+ ctx.lineWidth = 3;
593
+ ctx.stroke();
594
+ ctx.save();
595
+ ctx.translate(R, R);
596
+ ctx.rotate(s + a / 2);
597
+ ctx.textAlign = "right";
598
+ ctx.textBaseline = "middle";
599
+ ctx.fillStyle = "#fff";
600
+ ctx.font = `700 ${n > 10 ? 22 : 26}px Inter, Segoe UI, Arial, sans-serif`;
601
+ let name = wheel.items[i].name;
602
+ if (name.length > 20) name = name.slice(0, 19) + "…";
603
+ ctx.fillText(name, R - 26, 0);
604
+ ctx.restore();
605
+ }
606
+ ctx.beginPath();
607
+ ctx.arc(R, R, 46, 0, Math.PI * 2);
608
+ ctx.fillStyle = "#0b0b0f";
609
+ ctx.fill();
610
+ ctx.lineWidth = 6;
611
+ ctx.strokeStyle = "#fff";
612
+ ctx.stroke();
613
+ }
614
+ function spinWheel(pool) {
615
+ const picks = [...pool].sort(() => Math.random() - 0.5).slice(0, Math.min(12, pool.length));
616
+ wheel.items = picks.length >= 2 ? picks : [...picks, ...picks];
617
+ const n = wheel.items.length, a = (Math.PI * 2) / n;
618
+ const win = Math.floor(Math.random() * n);
619
+ wheel.winner = wheel.items[win];
620
+ // Land the winner's slice center under the pointer (top = -90°), after 6+ full turns
621
+ const base = -Math.PI / 2 - (win + 0.5) * a + (Math.random() - 0.5) * a * 0.6;
622
+ wheel.from = wheel.rot;
623
+ wheel.target = base + Math.PI * 2 * Math.ceil((wheel.from + Math.PI * 12 - base) / (Math.PI * 2));
624
+ wheel.start = performance.now();
625
+ wheel.spinning = true;
626
+ $("wheel-result").hidden = true;
627
+ $("wheel-play").hidden = true;
628
+ $("wheel-again").hidden = true;
629
+ $("wheel-skip").hidden = false;
630
+ cancelAnimationFrame(wheel.raf);
631
+ const step = (t) => {
632
+ const p = Math.min(1, (t - wheel.start) / wheel.dur);
633
+ const ease = 1 - Math.pow(1 - p, 4); // fast start, long slow finish
634
+ wheel.rot = wheel.from + (wheel.target - wheel.from) * ease;
635
+ drawWheel();
636
+ if (p < 1) wheel.raf = requestAnimationFrame(step);
637
+ else finishWheel();
401
638
  };
402
- apply(document.documentElement.dataset.theme === "light" ? "light" : "dark");
403
- btn.addEventListener("click", () => {
404
- const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
405
- apply(next);
406
- try { localStorage.setItem(THEME_KEY, next); } catch {}
639
+ wheel.raf = requestAnimationFrame(step);
640
+ }
641
+ function finishWheel() {
642
+ cancelAnimationFrame(wheel.raf);
643
+ wheel.rot = wheel.target;
644
+ wheel.spinning = false;
645
+ drawWheel();
646
+ const g = wheel.winner, url = logoUrl(g);
647
+ $("wheel-result-name").textContent = g.name;
648
+ const art = $("wheel-result-art");
649
+ art.style.backgroundImage = url ? `url("${url}"), ${gradient(g)}` : gradient(g);
650
+ art.textContent = url ? "" : initials(g.name);
651
+ $("wheel-result").hidden = false;
652
+ $("wheel-skip").hidden = true;
653
+ $("wheel-again").hidden = false;
654
+ $("wheel-play").hidden = false;
655
+ $("wheel-play").focus();
656
+ }
657
+ let wheelPool = [];
658
+ function openWheel(pool) {
659
+ wheelPool = pool;
660
+ $("wheel-wrap").hidden = false;
661
+ spinWheel(pool);
662
+ }
663
+ function closeWheel() {
664
+ cancelAnimationFrame(wheel.raf);
665
+ wheel.spinning = false;
666
+ $("wheel-wrap").hidden = true;
667
+ }
668
+ function bindWheel() {
669
+ $("wheel-skip").addEventListener("click", finishWheel);
670
+ $("wheel-again").addEventListener("click", () => spinWheel(wheelPool));
671
+ $("wheel-play").addEventListener("click", () => { const g = wheel.winner; closeWheel(); play(g.id); });
672
+ $("wheel-close").addEventListener("click", closeWheel);
673
+ $("wheel-wrap").addEventListener("click", (e) => { if (e.target.id === "wheel-wrap") closeWheel(); });
674
+ }
675
+
676
+ // ---------- Google Analytics (only when a measurement ID is set in config.js) ----------
677
+ // The tab title is disguised, so pages and games are reported by their real names instead.
678
+ function track(page, extra) {
679
+ if (!window.gtag) return;
680
+ window.gtag("event", "page_view", { page_title: page, page_location: location.href.split("#")[0] + (location.hash || "#home") });
681
+ if (extra) window.gtag("event", extra.name, extra.params);
682
+ }
683
+ function startAnalytics() {
684
+ const id = CFG.googleAnalytics;
685
+ if (!id || !/^G-[A-Z0-9]+$/i.test(id)) return;
686
+ window.dataLayer = window.dataLayer || [];
687
+ window.gtag = function () { window.dataLayer.push(arguments); };
688
+ window.gtag("js", new Date());
689
+ // We send page views ourselves (see track); no ad features
690
+ window.gtag("config", id, { send_page_view: false, allow_google_signals: false, allow_ad_personalization_signals: false });
691
+ loadScript(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`).catch(() => {});
692
+ }
693
+
694
+ // ---------- Live "people online" count ----------
695
+ // Needs a free Firebase Realtime Database (see SETUP.md). Without one, the count stays hidden.
696
+ function loadScript(src) {
697
+ return new Promise((ok, fail) => { const s = document.createElement("script"); s.src = src; s.onload = ok; s.onerror = fail; document.head.appendChild(s); });
698
+ }
699
+ // One shared Firebase connection for the online count and the chat (null when not configured)
700
+ let firebaseApp = null;
701
+ function getFirebase() {
702
+ if (!CFG.firebase || !CFG.firebase.databaseURL) return Promise.resolve(null);
703
+ if (!firebaseApp) {
704
+ firebaseApp = (async () => {
705
+ const v = "10.12.2";
706
+ await loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-app-compat.js`);
707
+ await Promise.all([
708
+ loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-database-compat.js`),
709
+ loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-auth-compat.js`),
710
+ ]);
711
+ return window.firebase.initializeApp(CFG.firebase, "rug");
712
+ })().catch((err) => { console.warn("Firebase unavailable:", err); return null; });
713
+ }
714
+ return firebaseApp;
715
+ }
716
+
717
+ async function startPresence() {
718
+ try {
719
+ const app = await getFirebase();
720
+ if (!app) return;
721
+ const db = app.database();
722
+ const me = db.ref("online").push();
723
+ db.ref(".info/connected").on("value", (snap) => {
724
+ if (!snap.val()) return;
725
+ me.onDisconnect().remove();
726
+ me.set(window.firebase.database.ServerValue.TIMESTAMP);
727
+ });
728
+ db.ref("online").on("value", (snap) => {
729
+ const n = snap.numChildren();
730
+ $("online-count").textContent = n <= 1 ? "Just you here right now" : `${n.toLocaleString()} people on right now`;
731
+ $("online").hidden = false;
732
+ });
733
+ } catch (err) {
734
+ console.warn("Online count unavailable:", err);
735
+ }
736
+ }
737
+
738
+ // ---------- Censored chat room ----------
739
+ // Two layers: this filter (stars out swears, refuses links/personal info) and the database's own
740
+ // rules (see SETUP.md), which reject the same things even if someone skips this page's code.
741
+ const BAD_ANYWHERE = ["fuck", "shit", "bitch", "cunt", "nigg", "faggot", "fagot", "whore", "slut", "pussy",
742
+ "penis", "vagina", "porn", "retard", "asshole", "bastard", "molest", "pedo", "dildo", "boob", "nazi", "hitler",
743
+ "kill yourself", "killyourself"];
744
+ const BAD_WHOLE_WORD = ["ass", "fag", "cum", "tit", "tits", "hoe", "kys", "sex", "nig", "dick", "cock", "rape",
745
+ "raped", "rapist", "raping", "damn", "piss", "crap", "wank", "twat", "prick"];
746
+ const LEET = { 0: "o", 1: "i", 3: "e", 4: "a", 5: "s", 7: "t", 8: "b", "@": "a", $: "s", "!": "i", "|": "i" };
747
+ // Real words that happen to contain a filtered one
748
+ const SAFE_WORDS = ["scunthorpe", "shiitake", "cockpit", "peacock", "hancock", "dickens", "assassin", "cocktail", "therapist"];
749
+ const plain = (s) => s.toLowerCase().replace(/[0134578@$!|]/g, (c) => LEET[c] || c).replace(/[^a-z ]/g, "");
750
+ const squash = (s) => plain(s).replace(/(.)\1+/g, "$1"); // "fuuuck" -> "fuck"
751
+ function isBadWord(word) {
752
+ const p = plain(word).replace(/ /g, ""), w = squash(word).replace(/ /g, "");
753
+ if (!w || SAFE_WORDS.includes(p)) return false;
754
+ // whole-word list: check before and after squashing, since squashing turns "ass" into "as"
755
+ return BAD_WHOLE_WORD.includes(p) || BAD_WHOLE_WORD.includes(w) ||
756
+ BAD_ANYWHERE.some((b) => w.includes(squash(b).replace(/ /g, "")));
757
+ }
758
+ // Returns { text } (with swears starred out) or { error } when the message can't be sent at all
759
+ function cleanMessage(raw) {
760
+ const text = raw.replace(/\s+/g, " ").trim();
761
+ if (!text) return { error: "Type a message first." };
762
+ if (/(https?:|www\.|discord\.gg|\b[a-z0-9-]+\.(com|net|org|io|gg|xyz|co|me|tv|ly|app|dev|link)\b)/i.test(text)) return { error: "Links aren't allowed in chat." };
763
+ if (/\S+@\S+\.\S+/.test(text)) return { error: "Don't share email addresses." };
764
+ if (/(\d[\s().-]*){7,}/.test(text)) return { error: "Don't share phone numbers." };
765
+ const masked = text.split(" ").map((w) => (isBadWord(w) ? "*".repeat(w.length) : w)).join(" ");
766
+ // spaced-out tricks like "f u c k" survive word-by-word checks; catch them on the whole line
767
+ const joined = squash(masked.split(" ").filter((w) => !SAFE_WORDS.includes(plain(w))).join(" ")).replace(/ /g, "");
768
+ if (BAD_ANYWHERE.some((b) => joined.includes(squash(b).replace(/ /g, "")))) return { error: "That message was blocked." };
769
+ return { text: masked };
770
+ }
771
+ function cleanName(raw) {
772
+ // Refuse (don't silently strip) other characters: a wrong or not-yet-saved admin code like "Code123!"
773
+ // must never be posted as a nickname
774
+ if (/[^\w .-]/.test(raw)) return { error: "Nicknames can only use letters, numbers, spaces, . _ and -." };
775
+ const name = raw.replace(/\s+/g, " ").trim().slice(0, 16);
776
+ if (name.length < 2) return { error: "Pick a nickname (2–16 letters)." };
777
+ if (name.split(/[ ._-]/).some(isBadWord) || isBadWord(name.replace(/[ ._-]/g, ""))) return { error: "Pick a different nickname." };
778
+ return { name };
779
+ }
780
+
781
+ const chat = { started: false, uid: null, lastSent: 0, admin: false, triedName: "" };
782
+ const ADMIN_NAME = "Richard"; // what the admin's messages show as (reserved: the database refuses it from non-admins)
783
+ function setAdminName() {
784
+ $("chat-name").value = ADMIN_NAME;
785
+ $("chat-name").readOnly = !!chat.admin;
786
+ }
787
+ function chatNote(text, err) { const n = $("chat-note"); n.textContent = text || ""; n.classList.toggle("err", !!err); }
788
+
789
+ // Who is this browser? Reuses a saved sign-in (Google for admins), otherwise signs in anonymously:
790
+ // no account, just a random per-browser id used for rate limits and bans.
791
+ async function ensureUser(app) {
792
+ const auth = app.auth();
793
+ const existing = await new Promise((ok) => { const off = auth.onAuthStateChanged((u) => { off(); ok(u); }); });
794
+ const user = existing || (await auth.signInAnonymously()).user;
795
+ chat.uid = user.uid;
796
+ // Admins are listed under chat/admins in the database (only editable from the Firebase console)
797
+ // The entry holds the admin code (the database only accepts the right one), so any entry means admin.
798
+ // If the code is changed later, old entries stop working on the database side.
799
+ try { chat.admin = (await app.database().ref(`chat/admins/${user.uid}`).once("value")).val() !== null; } catch { chat.admin = false; }
800
+ $("chat-log").classList.toggle("admin", chat.admin);
801
+ return user;
802
+ }
803
+
804
+ async function openChat() {
805
+ if (chat.started) return;
806
+ const app = await getFirebase();
807
+ if (!app) { $("chat-off").hidden = false; $("chat-log").hidden = true; $("chat-form").hidden = true; return; }
808
+ chat.started = true;
809
+ $("chat-form").hidden = false;
810
+ $("chat-name").value = store.get("rug-chat-name", "");
811
+ chatNote("Connecting…");
812
+ try {
813
+ await ensureUser(app);
814
+ } catch (err) {
815
+ chatNote("Chat couldn't connect. Try again later.", true);
816
+ return;
817
+ }
818
+ if (chat.admin) setAdminName();
819
+ chatNote(chat.admin ? "You're admin: 🗑 deletes a message, 🚫 bans its sender." : "");
820
+ const log = $("chat-log");
821
+ log.innerHTML = `<p class="chat-empty" id="chat-empty">No messages yet. Say hi!</p>`;
822
+ if (chat.ref) chat.ref.off(); // reconnecting (signed in/out): drop the old listener so messages don't double
823
+ const ref = chat.ref = app.database().ref("chat/messages").orderByChild("ts").limitToLast(60);
824
+ ref.on("child_added", (snap) => {
825
+ const m = snap.val();
826
+ if (!m || typeof m.text !== "string") return;
827
+ $("chat-empty")?.remove();
828
+ const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
829
+ const t = new Date(m.ts || Date.now());
830
+ const div = document.createElement("div");
831
+ div.className = "chat-msg" + (m.uid === chat.uid ? " me" : "");
832
+ div.dataset.key = snap.key;
833
+ div.dataset.uid = m.uid || "";
834
+ // filter again on display, in case anything slipped past older rules
835
+ const shown = cleanMessage(m.text).text || "[removed]";
836
+ div.innerHTML = `${m.admin ? `<span class="badge-admin">⭐ ADMIN</span>` : ""}<span class="who">${escapeHtml(m.name || "?")}</span>${escapeHtml(shown)}<span class="when">${t.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}</span>` +
837
+ `<button class="mod" type="button" data-mod="delete" title="Delete message">🗑</button><button class="mod" type="button" data-mod="ban" title="Ban this person">🚫</button>`;
838
+ log.appendChild(div);
839
+ while (log.children.length > 80) log.firstChild.remove();
840
+ if (nearBottom || m.uid === chat.uid) log.scrollTop = log.scrollHeight;
841
+ });
842
+ // Deleted by an admin: disappears for everyone
843
+ ref.on("child_removed", (snap) => {
844
+ log.querySelector(`[data-key="${CSS.escape(snap.key)}"]`)?.remove();
845
+ if (!log.querySelector(".chat-msg")) log.innerHTML = `<p class="chat-empty" id="chat-empty">No messages yet. Say hi!</p>`;
407
846
  });
408
847
  }
409
848
 
849
+ // ---------- Admin tools (chat) ----------
850
+ async function adminAction(e) {
851
+ const btn = e.target.closest("[data-mod]");
852
+ if (!btn || !chat.admin) return;
853
+ const row = btn.closest(".chat-msg");
854
+ const db = (await getFirebase()).database();
855
+ try {
856
+ if (btn.dataset.mod === "delete") {
857
+ await db.ref(`chat/messages/${row.dataset.key}`).remove();
858
+ } else if (btn.dataset.mod === "ban" && row.dataset.uid && row.dataset.uid !== chat.uid) {
859
+ if (!confirm("Ban this person from chat? They'll still be able to read.")) return;
860
+ await db.ref(`chat/banned/${row.dataset.uid}`).set(true);
861
+ chatNote("Banned. Their old messages stay until you delete them.");
862
+ }
863
+ } catch {
864
+ chatNote("That didn't work. Are you still signed in as an admin?", true);
865
+ }
866
+ }
867
+
868
+ async function renderAdmin() {
869
+ const app = await getFirebase();
870
+ $("admin-group").hidden = !app;
871
+ if (!app) return;
872
+ await ensureUser(app);
873
+ $("admin-status").textContent = chat.admin ? "Admin on this browser ✓" : "Not an admin on this browser";
874
+ $("admin-leave").hidden = !chat.admin;
875
+ $("admin-tools-row").hidden = !chat.admin;
876
+ }
877
+
878
+ // "#code" as a nickname: the database compares it with the secret code (which visitors can't read).
879
+ // Returns true if this browser is now admin.
880
+ async function claimAdmin(code) {
881
+ const app = await getFirebase();
882
+ try {
883
+ await app.database().ref(`chat/admins/${chat.uid}`).set(code);
884
+ } catch {
885
+ return false;
886
+ }
887
+ chat.admin = true;
888
+ $("chat-log").classList.add("admin");
889
+ return true;
890
+ }
891
+
892
+ function bindAdmin() {
893
+ $("chat-log").addEventListener("click", adminAction);
894
+ $("admin-leave").addEventListener("click", async () => {
895
+ try { await (await getFirebase()).database().ref(`chat/admins/${chat.uid}`).remove(); } catch {}
896
+ chat.admin = false;
897
+ chat.triedName = "";
898
+ $("chat-log").classList.remove("admin");
899
+ $("chat-name").readOnly = false;
900
+ $("chat-name").value = store.get("rug-chat-name", "");
901
+ await renderAdmin();
902
+ });
903
+ $("admin-clear").addEventListener("click", async () => {
904
+ if (!chat.admin || !confirm("Delete every chat message for everyone?")) return;
905
+ try { await (await getFirebase()).database().ref("chat/messages").remove(); }
906
+ catch { alert("Couldn't clear the chat."); }
907
+ });
908
+ }
909
+ async function sendChat(e) {
910
+ e.preventDefault();
911
+ const app = await getFirebase();
912
+ if (!app || !chat.uid) return;
913
+ // Typing the admin code as your nickname makes this browser admin (the database checks it;
914
+ // the code itself is never posted). Each new nickname is tried once.
915
+ const rawName = $("chat-name").value.trim();
916
+ if (!chat.admin && rawName && rawName !== chat.triedName) {
917
+ chat.triedName = rawName;
918
+ if (await claimAdmin(rawName)) {
919
+ setAdminName();
920
+ chatNote("You're admin now ✓ Your messages show as Richard, with 🗑 delete and 🚫 ban on every message.");
921
+ if (!$("chat-text").value.trim()) return;
922
+ }
923
+ }
924
+ const n = chat.admin ? { name: ADMIN_NAME } : cleanName(rawName);
925
+ if (n.error) { chatNote(n.error, true); $("chat-name").focus(); return; }
926
+ if (!chat.admin && n.name.toLowerCase() === ADMIN_NAME.toLowerCase()) { chatNote("That name is taken.", true); return; }
927
+ const m = cleanMessage($("chat-text").value);
928
+ if (m.error) { chatNote(m.error, true); return; }
929
+ const wait = 3000 - (Date.now() - chat.lastSent);
930
+ if (wait > 0) { chatNote(`Slow down. Wait ${Math.ceil(wait / 1000)}s.`, true); return; }
931
+ if (!chat.admin) store.set("rug-chat-name", n.name); // never save the admin code as a remembered name
932
+ const db = app.database();
933
+ const key = db.ref("chat/messages").push().key;
934
+ const TS = window.firebase.database.ServerValue.TIMESTAMP;
935
+ chat.lastSent = Date.now();
936
+ try {
937
+ await db.ref().update({
938
+ [`chat/messages/${key}`]: { name: n.name, text: m.text, ts: TS, uid: chat.uid, ...(chat.admin ? { admin: true } : {}) },
939
+ [`chat/last/${chat.uid}`]: TS,
940
+ });
941
+ $("chat-text").value = "";
942
+ chatNote("");
943
+ } catch {
944
+ chatNote("Message blocked (filtered word, or sending too fast).", true);
945
+ }
946
+ }
947
+
410
948
  // ---------- Arrow keys and controller move focus between tiles and buttons ----------
411
949
  function focusables() {
412
950
  const scope = els.player.hidden ? document : els.player;
@@ -435,8 +973,14 @@
435
973
  }
436
974
 
437
975
  function onKey(e) {
976
+ if (listeningForKey) return; // the Settings page is capturing a new panic key
438
977
  const keys = CFG.panicKeys || ["`", "]"];
439
978
  if (keys.includes(e.key)) { e.preventDefault(); panic(); return; }
979
+ if (!$("wheel-wrap").hidden) {
980
+ if (e.key === "Escape") closeWheel();
981
+ else if (e.key === " " && wheel.spinning) { e.preventDefault(); finishWheel(); } // space skips
982
+ return;
983
+ }
440
984
  if (!els.player.hidden) {
441
985
  if (e.key === "Escape" && document.activeElement !== els.frame) goBack();
442
986
  return;
@@ -532,12 +1076,133 @@
532
1076
  window.addEventListener("popstate", route);
533
1077
  }
534
1078
 
1079
+ // ---------- Home search: placeholder that types itself ----------
1080
+ let timer = 0; // shared, so restarting (from Settings) replaces the running animation
1081
+ function startTypingPlaceholder() {
1082
+ clearTimeout(timer);
1083
+ const input = els.homeInput;
1084
+ const short = games.filter((g) => g.name.length <= 24); // long names get cut off in the box
1085
+ const pick = () => short[Math.floor(Math.random() * short.length)].name;
1086
+ const fixed = [
1087
+ () => `Search ${games.length} games…`, () => `Try "${pick()}"`, () => "What are we playing today?",
1088
+ () => `Try "${pick()}"`, () => "Type a game, any game…", () => `How about "${pick()}"?`,
1089
+ () => "Racing, horror, rhythm…", () => `Try "${pick()}"`, () => "Press / to search from anywhere",
1090
+ ];
1091
+ if (!games.length) return;
1092
+ if (settings.typing === false || matchMedia("(prefers-reduced-motion: reduce)").matches) {
1093
+ input.placeholder = fixed[0]();
1094
+ return;
1095
+ }
1096
+ let line = 0, text = fixed[0](), shown = 0, deleting = false;
1097
+ const busy = () => document.activeElement === input || input.value;
1098
+ function tick() {
1099
+ if (busy()) { input.placeholder = `Search ${games.length} games…`; timer = setTimeout(tick, 400); return; }
1100
+ if (!deleting) {
1101
+ shown++;
1102
+ input.placeholder = text.slice(0, shown) + "▍";
1103
+ if (shown >= text.length) { deleting = true; input.placeholder = text; timer = setTimeout(tick, 1800); return; }
1104
+ timer = setTimeout(tick, 45 + Math.random() * 60); // uneven, like a real person typing
1105
+ } else {
1106
+ shown--;
1107
+ input.placeholder = text.slice(0, shown) + "▍";
1108
+ if (shown <= 0) {
1109
+ deleting = false;
1110
+ line = (line + 1) % fixed.length;
1111
+ text = fixed[line]();
1112
+ timer = setTimeout(tick, 350);
1113
+ return;
1114
+ }
1115
+ timer = setTimeout(tick, 22);
1116
+ }
1117
+ }
1118
+ tick();
1119
+ }
1120
+
1121
+ // ---------- Boot screen + auto-update ----------
1122
+ const BOOT_LINES = [
1123
+ "Loading… about 3 hours left", "Downloading more RAM…", "Calculating time left: 7 years…",
1124
+ "Loading 99%… still 99%…", "Estimated time: yes", "Skill issue detected, loading anyway…",
1125
+ "Installing 47 browser toolbars…", "Running on 3 megabytes and a dream…", "Buffering… (it's not the wifi, it's you)",
1126
+ "Spawning in…", "Respawning in 3… 2… 9…", "Rendering grass you'll never touch…",
1127
+ "Loading the loading screen…", "Downloading lag…", "Speedrunning the loading bar…",
1128
+ "Turning it off and on again…", "Searching for the any key…", "Patching bugs, adding new ones…",
1129
+ ];
1130
+ // Football jokes are mixed in, but never two in a row
1131
+ const TEAM_LINES = [
1132
+ "The Cowboys suck. Loading anyway…", "Loading… unlike the Cowboys in January",
1133
+ "This bar has more wins than the Cowboys…", "Giants are good. Loading respectfully…",
1134
+ "Loading at Giants speed (fast, obviously)…", "Buffering like a Browns offense…",
1135
+ "Searching for a Browns quarterback… still searching…", "Eagles fans booing the loading bar…",
1136
+ "Loading… quieter than Philly after a loss", "Steelers: 8-9 and somehow still loading…",
1137
+ "Loading with a Steelers-level 'we'll get 'em next year'…", "Ravens up big… loading the 4th-quarter collapse…",
1138
+ "Loading faster than a Ravens playoff exit…",
1139
+ ];
1140
+ const boot = { el: $("boot"), fill: $("boot-fill"), msg: $("boot-msg"), ver: $("boot-ver"), timer: 0, pct: 0 };
1141
+ function startBoot() {
1142
+ const used = new Set();
1143
+ let lastWasTeam = false;
1144
+ const next = () => {
1145
+ // ~1 in 3 lines is a team joke, but never right after another one
1146
+ const team = !lastWasTeam && Math.random() < 0.35;
1147
+ const pool = (team ? TEAM_LINES : BOOT_LINES).filter((l) => !used.has(l));
1148
+ const line = pool[Math.floor(Math.random() * pool.length)] || BOOT_LINES[0];
1149
+ used.add(line);
1150
+ lastWasTeam = team;
1151
+ return line;
1152
+ };
1153
+ boot.msg.textContent = next();
1154
+ boot.timer = setInterval(() => {
1155
+ boot.msg.textContent = next();
1156
+ boot.pct = Math.min(90, boot.pct + 8 + Math.random() * 14); // creeps up, saves the last bit for "done"
1157
+ boot.fill.style.width = `${boot.pct}%`;
1158
+ }, 420);
1159
+ }
1160
+ function finishBoot(text) {
1161
+ clearInterval(boot.timer);
1162
+ boot.fill.style.width = "100%";
1163
+ if (text) boot.msg.textContent = text;
1164
+ setTimeout(() => boot.el.classList.add("done"), 250);
1165
+ }
1166
+
1167
+ // When opened from a versioned unpkg link (e.g. an old bookmark), jump to the newest version.
1168
+ // Returns the URL to move to, or null to stay. Never waits longer than `ms`.
1169
+ async function newerVersionUrl(ms) {
1170
+ const pkg = CFG.npmPackage || "@ywkrlerche/richardwho";
1171
+ const here = location.pathname.match(/@(\d+)\.(\d+)\.(\d+)\//);
1172
+ if (location.hostname !== "unpkg.com" || !here) return null;
1173
+ try {
1174
+ const ctrl = new AbortController();
1175
+ const t = setTimeout(() => ctrl.abort(), ms);
1176
+ const res = await fetch(`https://unpkg.com/${pkg}/package.json?t=${Date.now()}`, { cache: "no-store", signal: ctrl.signal });
1177
+ clearTimeout(t);
1178
+ const latest = (await res.json()).version;
1179
+ const a = here.slice(1).map(Number), b = latest.split(".").map(Number);
1180
+ const newer = b[0] > a[0] || (b[0] === a[0] && (b[1] > a[1] || (b[1] === a[1] && b[2] > a[2])));
1181
+ boot.ver.textContent = newer ? `v${a.join(".")} → v${latest}` : `v${latest}`;
1182
+ return newer ? `https://unpkg.com/${pkg}@${latest}/index.html${location.hash}` : null;
1183
+ } catch {
1184
+ return null; // offline or slow: just open this version
1185
+ }
1186
+ }
1187
+
535
1188
  async function init() {
1189
+ loadSettings();
1190
+ if (settings.boot !== false) startBoot();
1191
+ else boot.el.classList.add("done"); // loading screen turned off in Settings
536
1192
  initTheme();
537
1193
  startClock();
538
1194
  startGamepad();
539
1195
  setPerRow(store.get(PER_ROW_KEY, 6));
540
1196
  bind();
1197
+ bindSettings();
1198
+ bindWheel();
1199
+ $("chat-form").addEventListener("submit", sendChat);
1200
+ bindAdmin();
1201
+ startAnalytics();
1202
+ startPresence();
1203
+ // long enough to read a joke or two (skipped when the loading screen is off)
1204
+ const minShow = new Promise((r) => setTimeout(r, settings.boot !== false ? 1500 : 0));
1205
+ const update = newerVersionUrl(2500);
541
1206
  try {
542
1207
  const res = await fetch("games.json", { cache: "no-cache" });
543
1208
  const data = await res.json();
@@ -546,9 +1211,19 @@
546
1211
  } catch (err) {
547
1212
  console.error(err);
548
1213
  els.homeSub.textContent = "Couldn't load the game list. Try refreshing.";
1214
+ finishBoot("Couldn't load the game list. Try refreshing.");
1215
+ return;
1216
+ }
1217
+ const target = await update;
1218
+ if (target) {
1219
+ clearInterval(boot.timer);
1220
+ boot.msg.textContent = "Found a newer version, updating…";
1221
+ boot.fill.style.width = "100%";
1222
+ location.replace(target);
549
1223
  return;
550
1224
  }
551
- els.homeInput.placeholder = `Search ${games.length} games…`;
1225
+ await minShow;
1226
+ startTypingPlaceholder();
552
1227
  els.homeSub.innerHTML = `${games.length} games · press <kbd>/</kbd> to search`;
553
1228
  els.footerCount.textContent = `${games.length} games`;
554
1229
  renderChips();
@@ -556,6 +1231,7 @@
556
1231
  renderGrid();
557
1232
  startMarquee();
558
1233
  route();
1234
+ finishBoot("Done");
559
1235
  }
560
1236
 
561
1237
  init();
package/config.js CHANGED
@@ -6,7 +6,7 @@ window.RUG_CONFIG = {
6
6
 
7
7
  // Branch the games live on. jsDelivr caches branches for up to 12h;
8
8
  // use a tag like "v1" here if you want instant, permanent URLs.
9
- branch: "main",
9
+ branch: "v1", // a fixed tag on every rug-games repo: jsDelivr caches tags for a year (branches only 12h)
10
10
 
11
11
  // Where the panic button sends you
12
12
  panicUrl: "https://classroom.google.com/",
@@ -14,6 +14,25 @@ window.RUG_CONFIG = {
14
14
  // Keys that trigger panic (KeyboardEvent.key values)
15
15
  panicKeys: ["`", "]"],
16
16
 
17
+ // Google Analytics 4 measurement ID, e.g. "G-ABC123XYZ" (from analytics.google.com).
18
+ // Leave null to turn analytics off. Shows visitors, live users and top games in YOUR GA dashboard.
19
+ googleAnalytics: "G-6VWCQ493PZ", // property "richardsunblockedgames"
20
+
21
+ // Live "people online" count on the home page and the chat room. Paste your Firebase web config here
22
+ // (see SETUP.md). Leave null to hide the count.
23
+ firebase: {
24
+ apiKey: "AIzaSyBxVxBFMwlSLx5FGP-Wna3ozHGZEAGXSKs",
25
+ authDomain: "richardwho-e8f22.firebaseapp.com",
26
+ databaseURL: "https://richardwho-e8f22-default-rtdb.firebaseio.com",
27
+ projectId: "richardwho-e8f22",
28
+ storageBucket: "richardwho-e8f22.firebasestorage.app",
29
+ messagingSenderId: "991096262738",
30
+ appId: "1:991096262738:web:8b6460399396051ee6cf8b",
31
+ },
32
+
33
+ // npm package the site is published as (used to find the newest version)
34
+ npmPackage: "@ywkrlerche/richardwho",
35
+
17
36
  // What the browser tab and history show instead of the site/game name.
18
37
  // Set tabTitle to null to show the real names again.
19
38
  tabTitle: "Google Classroom",
package/index.html CHANGED
@@ -11,9 +11,24 @@
11
11
  <script>
12
12
  // Apply saved theme before paint so there's no flash
13
13
  try { document.documentElement.dataset.theme = localStorage.getItem("rug-theme") || "dark"; } catch (e) {}
14
+ // Apply the saved tab disguise before anything else shows in the tab
15
+ try {
16
+ var s = JSON.parse(localStorage.getItem("rug-settings") || "{}");
17
+ if (s.tabTitle) document.title = s.tabTitle;
18
+ if (s.tabIcon) document.getElementById("tab-icon").href = s.tabIcon;
19
+ } catch (e) {}
14
20
  </script>
15
21
  </head>
16
22
  <body>
23
+ <!-- Boot screen: shows instantly while the site checks for a newer version and loads the game list -->
24
+ <div class="boot" id="boot" aria-live="polite">
25
+ <div class="boot-inner">
26
+ <div class="boot-word">richardwho</div>
27
+ <div class="boot-bar"><div class="boot-fill" id="boot-fill"></div></div>
28
+ <p class="boot-msg" id="boot-msg">Loading… about 3 hours left</p>
29
+ <p class="boot-ver" id="boot-ver"></p>
30
+ </div>
31
+ </div>
17
32
  <!-- Soft blue/orange glow behind everything -->
18
33
  <div class="aurora" aria-hidden="true"><span class="a1"></span><span class="a2"></span><span class="a3"></span></div>
19
34
 
@@ -31,11 +46,17 @@
31
46
  <button class="rail-btn" type="button" id="random-btn" title="Random game" aria-label="Random game">
32
47
  <svg viewBox="0 0 24 24"><path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm2.5 3a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm9 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3ZM12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3ZM7.5 15a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm9 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"/></svg>
33
48
  </button>
49
+ <button class="rail-btn" type="button" data-nav="chat" title="Chat" aria-label="Chat">
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
+ </button>
34
52
  <button class="rail-btn" type="button" data-nav="recent" title="Recently played" aria-label="Recently played">
35
53
  <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>
36
54
  </button>
37
55
  </div>
38
56
  <div class="rail-group bottom">
57
+ <button class="rail-btn" type="button" data-nav="settings" title="Settings" aria-label="Settings">
58
+ <svg viewBox="0 0 24 24"><path d="M19.4 13a7.6 7.6 0 0 0 0-2l2.1-1.6-2-3.5-2.5 1a7.4 7.4 0 0 0-1.7-1L15 3.3h-4l-.4 2.6a7.4 7.4 0 0 0-1.7 1l-2.5-1-2 3.5L6.6 11a7.6 7.6 0 0 0 0 2l-2.1 1.6 2 3.5 2.5-1a7.4 7.4 0 0 0 1.7 1l.4 2.6h4l.4-2.6a7.4 7.4 0 0 0 1.7-1l2.5 1 2-3.5L19.4 13ZM13 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7Z"/></svg>
59
+ </button>
39
60
  <button class="rail-btn" type="button" id="theme-btn" title="Toggle theme" aria-label="Toggle theme">
40
61
  <svg viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.39 5.39 0 0 1-4.4 2.26 5.4 5.4 0 0 1-3.14-9.8A9.4 9.4 0 0 0 12 3Z"/></svg>
41
62
  </button>
@@ -59,6 +80,7 @@
59
80
  <span class="sweep" aria-hidden="true"></span>
60
81
  </form>
61
82
  <p class="home-sub" id="home-sub"></p>
83
+ <p class="online" id="online" hidden><span class="online-dot"></span><span id="online-count"></span></p>
62
84
  </section>
63
85
 
64
86
  <!-- GAMES -->
@@ -109,8 +131,110 @@
109
131
  <p class="empty" id="empty" hidden>No games match that search.</p>
110
132
  </section>
111
133
  </section>
134
+
135
+ <!-- CHAT -->
136
+ <section id="view-chat" class="view chat" hidden>
137
+ <header class="games-head"><div><h1>Chat</h1><p class="lede">Be nice. Messages are filtered, and links and personal info are blocked.</p></div></header>
138
+ <div class="chat-box">
139
+ <div class="chat-off" id="chat-off" hidden>
140
+ <p><strong>Chat isn't switched on yet.</strong></p>
141
+ <p>It needs the site's free Firebase database (see SETUP.md).</p>
142
+ </div>
143
+ <div class="chat-log" id="chat-log" aria-live="polite"></div>
144
+ <form class="chat-form" id="chat-form" autocomplete="off" hidden>
145
+ <input id="chat-name" class="chat-name" maxlength="16" placeholder="Nickname" spellcheck="false" aria-label="Nickname">
146
+ <input id="chat-text" class="chat-text" maxlength="200" placeholder="Say something…" aria-label="Message">
147
+ <button class="pill-btn" type="submit" id="chat-send">Send</button>
148
+ </form>
149
+ <p class="chat-note" id="chat-note"></p>
150
+ </div>
151
+ </section>
152
+
153
+ <!-- SETTINGS -->
154
+ <section id="view-settings" class="view settings" hidden>
155
+ <header class="games-head"><div><h1>Settings</h1><p class="lede">Saved on this device only.</p></div></header>
156
+
157
+ <div class="set-group">
158
+ <h2>Tab disguise</h2>
159
+ <p class="set-help">What the browser tab and your history show instead of the site's name.</p>
160
+ <div class="disguises" id="disguises"></div>
161
+ </div>
162
+
163
+ <div class="set-group">
164
+ <h2>Panic button</h2>
165
+ <label class="set-row"><span>Goes to</span>
166
+ <select id="set-panic-url">
167
+ <option value="https://classroom.google.com/">Google Classroom</option>
168
+ <option value="https://docs.google.com/">Google Docs</option>
169
+ <option value="https://drive.google.com/">Google Drive</option>
170
+ <option value="https://www.google.com/">Google</option>
171
+ <option value="custom">Custom website…</option>
172
+ </select>
173
+ </label>
174
+ <label class="set-row" id="set-panic-custom-row" hidden><span>Custom website</span>
175
+ <input id="set-panic-custom" type="url" placeholder="https://…" spellcheck="false">
176
+ </label>
177
+ <div class="set-row"><span>Panic key</span>
178
+ <button class="pill-btn key-btn" type="button" id="set-panic-key">`</button>
179
+ <small class="set-hint" id="set-panic-key-hint">Click, then press any key</small>
180
+ </div>
181
+ </div>
182
+
183
+ <div class="set-group">
184
+ <h2>Look &amp; feel</h2>
185
+ <label class="set-row"><span>Theme</span>
186
+ <select id="set-theme"><option value="dark">Dark</option><option value="light">Light</option></select>
187
+ </label>
188
+ <label class="set-row"><span>Clock</span>
189
+ <select id="set-clock"><option value="12">12-hour</option><option value="24">24-hour</option></select>
190
+ </label>
191
+ <label class="set-row"><span>Games per row</span>
192
+ <select id="set-per-row"><option>5</option><option>6</option><option>7</option><option>8</option></select>
193
+ </label>
194
+ <label class="set-row"><span>Typing search box</span><input type="checkbox" id="set-typing"></label>
195
+ <label class="set-row"><span>Funny loading screen</span><input type="checkbox" id="set-boot"></label>
196
+ </div>
197
+
198
+ <div class="set-group" id="admin-group" hidden>
199
+ <h2>Chat admin</h2>
200
+ <p class="set-help">Admins can delete chat messages and ban people. To become admin, type the admin code as your chat nickname and press Send. Your messages then show as Richard.</p>
201
+ <div class="set-row"><span id="admin-status">Not an admin on this browser</span>
202
+ <button class="pill-btn" type="button" id="admin-leave" hidden>Stop being admin here</button>
203
+ </div>
204
+ <div class="set-row" id="admin-tools-row" hidden><span>Chat</span>
205
+ <button class="pill-btn danger" type="button" id="admin-clear">Clear all chat</button>
206
+ </div>
207
+ </div>
208
+
209
+ <div class="set-group">
210
+ <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>
213
+ </div>
214
+ </section>
112
215
  </main>
113
216
 
217
+ <!-- RANDOM GAME WHEEL -->
218
+ <div class="wheel-wrap" id="wheel-wrap" hidden role="dialog" aria-label="Random game wheel">
219
+ <div class="wheel-card">
220
+ <button class="wheel-x" type="button" id="wheel-close" aria-label="Close">✕</button>
221
+ <h2>Spin for a game</h2>
222
+ <div class="wheel-stage">
223
+ <div class="wheel-pointer" aria-hidden="true"></div>
224
+ <canvas id="wheel" width="720" height="720"></canvas>
225
+ </div>
226
+ <div class="wheel-result" id="wheel-result" hidden>
227
+ <div class="wheel-result-art" id="wheel-result-art"></div>
228
+ <div><p class="wheel-result-label">You got</p><p class="wheel-result-name" id="wheel-result-name"></p></div>
229
+ </div>
230
+ <div class="wheel-actions">
231
+ <button class="pill-btn" type="button" id="wheel-skip">Skip ⏭</button>
232
+ <button class="pill-btn" type="button" id="wheel-again" hidden>Spin again</button>
233
+ <button class="pill-btn play" type="button" id="wheel-play" hidden>▶ Play</button>
234
+ </div>
235
+ </div>
236
+ </div>
237
+
114
238
  <!-- PLAYER -->
115
239
  <section class="player" id="player" hidden aria-label="Game player">
116
240
  <div class="player-bar">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ywkrlerche/richardwho",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
4
4
  "description": "Richards Unblocked Games",
5
5
  "license": "UNLICENSED",
6
6
  "files": [
package/style.css CHANGED
@@ -45,6 +45,20 @@ button { cursor: pointer; }
45
45
  kbd { font: 600 11px ui-monospace, Consolas, monospace; padding: 1px 6px; border-radius: 5px; border: 1px solid var(--line); background: var(--soft); }
46
46
  :focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
47
47
 
48
+ /* ---------- Boot screen ---------- */
49
+ .boot {
50
+ position: fixed; inset: 0; z-index: 100; display: grid; place-items: center;
51
+ background: radial-gradient(60vw 30vh at 50% 30%, var(--glow-blue), transparent 70%), var(--bg);
52
+ transition: opacity .35s ease, visibility .35s;
53
+ }
54
+ .boot.done { opacity: 0; visibility: hidden; pointer-events: none; }
55
+ .boot-inner { width: min(420px, 86vw); text-align: center; }
56
+ .boot-word { font-size: clamp(44px, 8vw, 72px); font-weight: 800; letter-spacing: -.055em; margin-bottom: 26px; }
57
+ .boot-bar { height: 3px; background: var(--soft-2); overflow: hidden; border-radius: 3px; }
58
+ .boot-fill { height: 100%; width: 0; background: var(--text); transition: width .25s ease; }
59
+ .boot-msg { margin: 14px 0 0; color: var(--muted); font-size: 13px; min-height: 1.4em; font-variant-numeric: tabular-nums; }
60
+ .boot-ver { margin: 6px 0 0; color: var(--muted); font-size: 11px; opacity: .6; min-height: 1.2em; }
61
+
48
62
  /* ---------- Aurora glow ---------- */
49
63
  .aurora { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
50
64
  .aurora span { position: absolute; border-radius: 50%; filter: blur(90px); }
@@ -179,6 +193,80 @@ body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
179
193
  .marquee .tile { width: clamp(300px, 28vw, 440px); aspect-ratio: 16 / 9; flex: none; }
180
194
  .marquee .tile-label { font-size: 17px; bottom: 14px; left: 16px; }
181
195
 
196
+ /* ---------- Online count (home) ---------- */
197
+ .online { margin: 10px 0 0; display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; }
198
+ .online-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 0 0 rgba(34,197,94,.6); animation: pulse 2s infinite; }
199
+ @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(34,197,94,.55); } 70% { box-shadow: 0 0 0 8px rgba(34,197,94,0); } 100% { box-shadow: 0 0 0 0 rgba(34,197,94,0); } }
200
+
201
+ /* ---------- Settings ---------- */
202
+ .settings { padding-top: 26px; padding-bottom: 60px; max-width: 760px; }
203
+ .set-group { border: 1px solid var(--line); border-radius: 6px; background: color-mix(in srgb, var(--panel) 85%, transparent); padding: 18px 20px; margin-bottom: 16px; }
204
+ .set-group h2 { margin: 0 0 4px; font-size: 16px; }
205
+ .set-help { margin: 0 0 14px; color: var(--muted); font-size: 13px; }
206
+ .set-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--line); }
207
+ .set-group h2 + .set-row { margin-top: 10px; }
208
+ .set-row > span { flex: 1; font-size: 14px; }
209
+ .set-row select, .set-row input[type="url"] { height: 36px; min-width: 200px; padding: 0 10px; border: 1px solid var(--line-2); border-radius: 4px; background: var(--panel); color: var(--text); }
210
+ .set-row input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--text); }
211
+ .set-hint { color: var(--muted); font-size: 12px; }
212
+ .settings .pill-btn { color: var(--text); border-color: var(--line-2); background: var(--soft); }
213
+ .key-btn { min-width: 64px; font-family: ui-monospace, Consolas, monospace; }
214
+ .key-btn.listening { border-color: var(--text); }
215
+ .disguises { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
216
+ .disguise {
217
+ display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 4px; text-align: left;
218
+ border: 1px solid var(--line-2); background: var(--soft); color: var(--text); font-size: 13px; font-weight: 600;
219
+ }
220
+ .disguise img { width: 18px; height: 18px; flex: none; }
221
+ .disguise .none-icon { width: 18px; height: 18px; flex: none; display: grid; place-items: center; font-size: 13px; }
222
+ .disguise[aria-pressed="true"] { border-color: var(--text); background: var(--soft-2); box-shadow: inset 0 0 0 1px var(--text); }
223
+
224
+ /* ---------- Chat ---------- */
225
+ .chat { padding-top: 26px; padding-bottom: 40px; max-width: 820px; }
226
+ .chat-box { border: 1px solid var(--line); border-radius: 6px; background: color-mix(in srgb, var(--panel) 88%, transparent); display: flex; flex-direction: column; height: min(68vh, 640px); }
227
+ .chat-log { flex: 1; overflow-y: auto; padding: 14px 16px; display: flex; flex-direction: column; gap: 8px; }
228
+ .chat-msg { font-size: 14px; line-height: 1.4; word-wrap: break-word; }
229
+ .chat-msg .who { font-weight: 700; margin-right: 6px; }
230
+ .chat-msg .when { color: var(--muted); font-size: 11px; margin-left: 6px; }
231
+ .chat-msg.me .who { text-decoration: underline; text-underline-offset: 3px; }
232
+ .chat-empty { color: var(--muted); font-size: 13px; text-align: center; margin: auto; }
233
+ .chat-form { display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--line); }
234
+ .chat-form input { height: 40px; padding: 0 12px; border: 1px solid var(--line-2); border-radius: 4px; background: var(--bg); color: var(--text); }
235
+ .chat-name { width: 130px; flex: none; }
236
+ .chat-text { flex: 1; min-width: 0; }
237
+ .chat .pill-btn { color: var(--bg); background: var(--text); border-color: var(--text); height: 40px; padding: 0 18px; }
238
+ .chat-note { margin: 0; padding: 0 12px 10px; min-height: 1.3em; color: var(--muted); font-size: 12px; }
239
+ .chat-note.err { color: var(--danger); }
240
+ .chat-off { margin: auto; text-align: center; color: var(--muted); padding: 30px; }
241
+ .chat-off p { margin: 4px 0; }
242
+ .chat-msg .mod { display: none; margin-left: 8px; border: 1px solid var(--line-2); background: var(--soft); color: var(--text); border-radius: 3px; font-size: 11px; padding: 1px 6px; }
243
+ .chat-msg .mod:hover { background: color-mix(in srgb, var(--danger) 30%, transparent); }
244
+ .chat-log.admin .chat-msg .mod { display: inline-block; }
245
+ .chat-msg .badge-admin { color: #facc15; font-size: 11px; font-weight: 700; margin-right: 6px; }
246
+ .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
+ @media (max-width: 720px) { .chat-name { width: 90px; } }
248
+
249
+ /* ---------- Random game wheel ---------- */
250
+ .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
+ .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); }
252
+ .wheel-card h2 { margin: 0 0 12px; font-size: 18px; }
253
+ .wheel-x { position: absolute; top: 10px; right: 10px; width: 32px; height: 32px; border: 0; border-radius: 4px; background: none; color: var(--muted); font-size: 16px; }
254
+ .wheel-x:hover { color: var(--text); background: var(--soft); }
255
+ .wheel-stage { position: relative; width: min(360px, 78vw); aspect-ratio: 1; margin: 0 auto; }
256
+ .wheel-stage canvas { width: 100%; height: 100%; display: block; }
257
+ .wheel-pointer {
258
+ position: absolute; left: 50%; top: -6px; z-index: 1; width: 0; height: 0; transform: translateX(-50%);
259
+ border-left: 13px solid transparent; border-right: 13px solid transparent; border-top: 24px solid var(--text);
260
+ filter: drop-shadow(0 2px 4px rgba(0,0,0,.6));
261
+ }
262
+ .wheel-result { display: flex; align-items: center; gap: 12px; margin: 16px auto 0; padding: 10px; border: 1px solid var(--line-2); border-radius: 6px; text-align: left; max-width: 340px; }
263
+ .wheel-result-art { width: 64px; aspect-ratio: 4/3; border-radius: 4px; flex: none; background: #222 center / cover no-repeat; display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 13px; }
264
+ .wheel-result-label { margin: 0; color: var(--muted); font-size: 12px; }
265
+ .wheel-result-name { margin: 2px 0 0; font-size: 16px; font-weight: 800; }
266
+ .wheel-actions { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
267
+ .wheel-card .pill-btn { color: var(--text); border-color: var(--line-2); background: var(--soft); height: 38px; padding: 0 18px; }
268
+ .wheel-card .pill-btn.play { background: var(--text); color: var(--bg); border-color: var(--text); }
269
+
182
270
  /* ---------- Player ---------- */
183
271
  .player { position: fixed; inset: 0; z-index: 40; display: flex; flex-direction: column; background: #000; }
184
272
  .player-bar { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: #0a0a0c; border-bottom: 1px solid rgba(255,255,255,.08); color: #fff; }