easter-egg-quest 1.0.16 → 1.0.18

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.
@@ -82,19 +82,14 @@ const DEFAULT_SCRIPT = {
82
82
  ],
83
83
  stage3Success: [
84
84
  "rhythm was the answer",
85
- "not stopping, not rushing",
86
- "but the dance between them",
87
- "the third egg appears",
88
- "for those who breathe"
85
+ "you found the third egg",
86
+ "nice work"
89
87
  ],
90
88
  // ── Finale ────────────────────────────────────────────────────────────
91
89
  finale: [
92
- "you were looking for three eggs",
93
- "but you found something else",
94
- "stillness",
95
- "motion",
96
- "rhythm",
97
- "this is where life appears"
90
+ "all three eggs found",
91
+ "stillness, motion, rhythm",
92
+ "that's the whole set"
98
93
  ],
99
94
  // ── Results / Share ───────────────────────────────────────────────────
100
95
  results: [
@@ -520,7 +515,25 @@ function isElementVisible(el) {
520
515
  if (rect.width === 0 || rect.height === 0) return false;
521
516
  const style = getComputedStyle(el);
522
517
  if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false;
523
- return rect.top < window.innerHeight && rect.bottom > 0 && rect.left < window.innerWidth && rect.right > 0;
518
+ const inViewport = rect.top < window.innerHeight && rect.bottom > 0 && rect.left < window.innerWidth && rect.right > 0;
519
+ if (!inViewport) return false;
520
+ let parent = el.parentElement;
521
+ while (parent && parent !== document.body) {
522
+ const parentStyle = getComputedStyle(parent);
523
+ const overflowClips = [
524
+ parentStyle.overflow,
525
+ parentStyle.overflowX,
526
+ parentStyle.overflowY
527
+ ].some((value) => ["auto", "scroll", "hidden", "clip"].includes(value));
528
+ if (overflowClips) {
529
+ const parentRect = parent.getBoundingClientRect();
530
+ if (rect.bottom <= parentRect.top || rect.top >= parentRect.bottom || rect.right <= parentRect.left || rect.left >= parentRect.right) {
531
+ return false;
532
+ }
533
+ }
534
+ parent = parent.parentElement;
535
+ }
536
+ return true;
524
537
  }
525
538
  function isDangerousElement(el) {
526
539
  const text = (el.textContent ?? "").toLowerCase().trim();
@@ -528,26 +541,6 @@ function isDangerousElement(el) {
528
541
  const combined = `${text} ${ariaLabel}`;
529
542
  return DANGEROUS_KEYWORDS.some((kw) => combined.includes(kw));
530
543
  }
531
- function findEligibleEntryElements(containerSelector, excludeSelectors = []) {
532
- const container = containerSelector ? document.querySelector(containerSelector) ?? document.body : document.body;
533
- const candidates = container.querySelectorAll(
534
- 'a, button, [role="button"], nav a, nav button, .cta, [data-easter-entry]'
535
- );
536
- const excludeSet = /* @__PURE__ */ new Set();
537
- for (const sel of excludeSelectors) {
538
- document.querySelectorAll(sel).forEach((el) => excludeSet.add(el));
539
- }
540
- const eligible = [];
541
- candidates.forEach((el) => {
542
- if (excludeSet.has(el)) return;
543
- if (!isElementVisible(el)) return;
544
- if (isDangerousElement(el)) return;
545
- const rect = el.getBoundingClientRect();
546
- if (rect.width < 30 || rect.height < 20) return;
547
- eligible.push(el);
548
- });
549
- return eligible;
550
- }
551
544
  function pickRandom(arr) {
552
545
  if (arr.length === 0) return void 0;
553
546
  return arr[Math.floor(Math.random() * arr.length)];
@@ -577,6 +570,11 @@ class HiddenEntry {
577
570
  this._intersectionObserver = null;
578
571
  this._mutationDebounce = null;
579
572
  this._navigationHandler = null;
573
+ this._bootstrapObserver = null;
574
+ this._retryTimer = null;
575
+ this._fallbackTimer = null;
576
+ this._retryCount = 0;
577
+ this._historyPatched = false;
580
578
  this.config = config;
581
579
  this.script = script;
582
580
  this.onFound = onFound;
@@ -586,16 +584,10 @@ class HiddenEntry {
586
584
  this._createFallbackEntry();
587
585
  return;
588
586
  }
589
- const eligible = findEligibleEntryElements(
590
- this.config.hiddenEntry.selector,
591
- this.config.hiddenEntry.excludeSelectors
592
- );
593
- if (eligible.length === 0) {
594
- this._createFallbackEntry();
595
- return;
596
- }
597
- this._injectIntoExisting(eligible);
598
587
  this._startHintEscalation();
588
+ this._startBootstrapWatch();
589
+ this._scheduleFallback();
590
+ this._attemptInjection(0);
599
591
  }
600
592
  cleanup() {
601
593
  var _a2, _b2, _c, _d;
@@ -611,6 +603,7 @@ class HiddenEntry {
611
603
  this.targetElement.classList.remove("eeq-entry-target");
612
604
  }
613
605
  this._stopWatchdog();
606
+ this._stopBootstrapWatch();
614
607
  (_a2 = this._injectedElement) == null ? void 0 : _a2.remove();
615
608
  this._injectedElement = null;
616
609
  (_b2 = this.hintContainer) == null ? void 0 : _b2.remove();
@@ -623,17 +616,11 @@ class HiddenEntry {
623
616
  this._attachToElement(this.targetElement);
624
617
  }
625
618
  // ─── Inject trigger text inside an existing element ─────────────────
626
- _injectIntoExisting(_eligible) {
627
- const candidates = Array.from(
628
- document.querySelectorAll(
629
- "p, li, figcaption, blockquote, h4, h3, h2, h1, header a, nav a, nav button"
630
- )
631
- ).filter((el) => {
632
- var _a2;
633
- const text = ((_a2 = el.textContent) == null ? void 0 : _a2.trim()) ?? "";
634
- if (text.length < 2 || el.closest("[data-eeq]")) return false;
635
- return isElementVisible(el);
636
- });
619
+ _injectIntoExisting() {
620
+ var _a2;
621
+ const root = this._getSearchRoot();
622
+ const candidates = this._findTextCandidates(root);
623
+ if (candidates.length === 0) return false;
637
624
  const sorted = candidates.sort((a, b) => {
638
625
  const aNav = a.closest("nav") !== null || a.closest("header") !== null ? 1 : 0;
639
626
  const bNav = b.closest("nav") !== null || b.closest("header") !== null ? 1 : 0;
@@ -644,8 +631,8 @@ class HiddenEntry {
644
631
  });
645
632
  const prints = sorted.map(
646
633
  (el) => {
647
- var _a2;
648
- return `${el.tagName}:${(((_a2 = el.textContent) == null ? void 0 : _a2.trim()) ?? "").slice(0, 30)}`;
634
+ var _a3;
635
+ return `${el.tagName}:${(((_a3 = el.textContent) == null ? void 0 : _a3.trim()) ?? "").slice(0, 30)}`;
649
636
  }
650
637
  );
651
638
  const HISTORY_KEY = "eeq_trigger_history";
@@ -677,8 +664,8 @@ class HiddenEntry {
677
664
  ];
678
665
  const rotatedPrints = rotated.map(
679
666
  (el) => {
680
- var _a2;
681
- return `${el.tagName}:${(((_a2 = el.textContent) == null ? void 0 : _a2.trim()) ?? "").slice(0, 30)}`;
667
+ var _a3;
668
+ return `${el.tagName}:${(((_a3 = el.textContent) == null ? void 0 : _a3.trim()) ?? "").slice(0, 30)}`;
682
669
  }
683
670
  );
684
671
  const unused = rotated.filter((_, i) => !usedSet.has(rotatedPrints[i]));
@@ -692,6 +679,7 @@ class HiddenEntry {
692
679
  const isInline = host.tagName === "A" || host.tagName === "BUTTON" || host.tagName === "LI" || host.closest("nav") !== null;
693
680
  const span = document.createElement("span");
694
681
  span.textContent = isInline ? " · start hunt" : " start hunt";
682
+ span.setAttribute("data-eeq", "trigger");
695
683
  span.style.cssText = `
696
684
  font: inherit;
697
685
  color: inherit;
@@ -719,10 +707,161 @@ class HiddenEntry {
719
707
  this._injectedElement = span;
720
708
  this.targetElement = span;
721
709
  this._attachToElement(span);
710
+ this._retryCount = 0;
711
+ this._clearRetryTimer();
712
+ this._clearFallbackTimer();
713
+ (_a2 = this.fallbackButton) == null ? void 0 : _a2.remove();
714
+ this.fallbackButton = null;
715
+ this._stopBootstrapWatch();
722
716
  this._startWatchdog();
723
- return;
717
+ return true;
718
+ }
719
+ return false;
720
+ }
721
+ _attemptInjection(delayMs) {
722
+ this._clearRetryTimer();
723
+ this._retryTimer = setTimeout(() => {
724
+ if (this._destroyed || this._injectedElement) return;
725
+ const ok = this._injectIntoExisting();
726
+ if (!ok) this._scheduleNextRetry();
727
+ }, delayMs);
728
+ }
729
+ _scheduleNextRetry() {
730
+ if (this._destroyed || this._injectedElement) return;
731
+ const delays = [300, 900, 1800, 3200, 5e3, 8e3];
732
+ const nextDelay = delays[Math.min(this._retryCount, delays.length - 1)];
733
+ this._retryCount += 1;
734
+ this._attemptInjection(nextDelay);
735
+ }
736
+ _scheduleFallback() {
737
+ if (this.fallbackButton || this._fallbackTimer) return;
738
+ this._fallbackTimer = setTimeout(() => {
739
+ if (this._destroyed || this._injectedElement || this.fallbackButton) return;
740
+ this._createFallbackEntry();
741
+ }, 1e4);
742
+ }
743
+ _clearRetryTimer() {
744
+ if (this._retryTimer) {
745
+ clearTimeout(this._retryTimer);
746
+ this._retryTimer = null;
747
+ }
748
+ }
749
+ _clearFallbackTimer() {
750
+ if (this._fallbackTimer) {
751
+ clearTimeout(this._fallbackTimer);
752
+ this._fallbackTimer = null;
753
+ }
754
+ }
755
+ _getSearchRoot() {
756
+ if (!this.config.hiddenEntry.selector) return document.body;
757
+ return document.querySelector(this.config.hiddenEntry.selector) ?? document.body;
758
+ }
759
+ _findTextCandidates(root) {
760
+ var _a2;
761
+ const selector = [
762
+ "p",
763
+ "li",
764
+ "figcaption",
765
+ "blockquote",
766
+ "caption",
767
+ "label",
768
+ "td",
769
+ "th",
770
+ "h1",
771
+ "h2",
772
+ "h3",
773
+ "h4",
774
+ "h5",
775
+ "h6",
776
+ "a",
777
+ "button",
778
+ '[role="button"]',
779
+ '[role="link"]',
780
+ "div",
781
+ "span",
782
+ "article",
783
+ "section",
784
+ "summary",
785
+ "[data-easter-entry]",
786
+ "[mat-button]",
787
+ "[mat-list-item]",
788
+ ".mat-mdc-button",
789
+ ".mat-mdc-list-item",
790
+ ".ag-cell",
791
+ ".ag-header-cell"
792
+ ].join(", ");
793
+ const excludeSet = /* @__PURE__ */ new Set();
794
+ for (const sel of this.config.hiddenEntry.excludeSelectors) {
795
+ (_a2 = root.querySelectorAll) == null ? void 0 : _a2.call(root, sel).forEach((el) => excludeSet.add(el));
796
+ }
797
+ return Array.from(root.querySelectorAll(selector)).filter((el) => {
798
+ var _a3, _b2;
799
+ if (!(el instanceof HTMLElement)) return false;
800
+ if (excludeSet.has(el)) return false;
801
+ if (el.closest("[data-eeq]")) return false;
802
+ if (el.closest("script, style, noscript, svg, canvas, form")) return false;
803
+ if (isDangerousElement(el)) return false;
804
+ if (!isElementVisible(el)) return false;
805
+ const text = ((_a3 = el.innerText) == null ? void 0 : _a3.trim()) || ((_b2 = el.textContent) == null ? void 0 : _b2.trim()) || "";
806
+ if (text.length < 6 || text.length > 220) return false;
807
+ const childElementCount = el.children.length;
808
+ if (childElementCount > 12) return false;
809
+ const rect = el.getBoundingClientRect();
810
+ if (rect.width < 18 || rect.height < 12) return false;
811
+ return true;
812
+ });
813
+ }
814
+ _startBootstrapWatch() {
815
+ this._stopBootstrapWatch();
816
+ const root = this._getSearchRoot();
817
+ const observerTarget = root instanceof HTMLElement ? root : document.body;
818
+ this._bootstrapObserver = new MutationObserver(() => {
819
+ if (this._destroyed || this._injectedElement) return;
820
+ if (this._mutationDebounce) clearTimeout(this._mutationDebounce);
821
+ this._mutationDebounce = setTimeout(() => {
822
+ if (this._destroyed || this._injectedElement) return;
823
+ this._attemptInjection(150);
824
+ }, 250);
825
+ });
826
+ this._bootstrapObserver.observe(observerTarget, { childList: true, subtree: true });
827
+ this._navigationHandler = () => {
828
+ if (this._destroyed || this._injectedElement) return;
829
+ this._attemptInjection(700);
830
+ };
831
+ window.addEventListener("popstate", this._navigationHandler);
832
+ window.addEventListener("hashchange", this._navigationHandler);
833
+ if (!this._historyPatched && typeof window.history !== "undefined") {
834
+ this._historyPatched = true;
835
+ const patchHistoryMethod = (method) => {
836
+ const original = window.history[method].bind(window.history);
837
+ window.history[method] = (...args) => {
838
+ const result = original(...args);
839
+ window.dispatchEvent(new Event("eeq:navigation"));
840
+ return result;
841
+ };
842
+ };
843
+ patchHistoryMethod("pushState");
844
+ patchHistoryMethod("replaceState");
845
+ }
846
+ window.addEventListener("eeq:navigation", this._navigationHandler);
847
+ }
848
+ _stopBootstrapWatch() {
849
+ if (this._bootstrapObserver) {
850
+ this._bootstrapObserver.disconnect();
851
+ this._bootstrapObserver = null;
852
+ }
853
+ this._clearRetryTimer();
854
+ this._clearFallbackTimer();
855
+ if (this._mutationDebounce) {
856
+ clearTimeout(this._mutationDebounce);
857
+ this._mutationDebounce = null;
858
+ }
859
+ if (this._navigationHandler) {
860
+ window.removeEventListener("popstate", this._navigationHandler);
861
+ window.removeEventListener("hashchange", this._navigationHandler);
862
+ window.removeEventListener("eeq:navigation", this._navigationHandler);
863
+ this._navigationHandler = null;
724
864
  }
725
- this._createFallbackEntry();
726
865
  }
727
866
  /**
728
867
  * Watch for the injected trigger element being removed from the DOM
@@ -769,6 +908,7 @@ class HiddenEntry {
769
908
  };
770
909
  window.addEventListener("popstate", this._navigationHandler);
771
910
  window.addEventListener("hashchange", this._navigationHandler);
911
+ window.addEventListener("eeq:navigation", this._navigationHandler);
772
912
  }
773
913
  /**
774
914
  * Walk up the DOM to find a stable ancestor that won't be replaced
@@ -803,6 +943,7 @@ class HiddenEntry {
803
943
  if (this._navigationHandler) {
804
944
  window.removeEventListener("popstate", this._navigationHandler);
805
945
  window.removeEventListener("hashchange", this._navigationHandler);
946
+ window.removeEventListener("eeq:navigation", this._navigationHandler);
806
947
  this._navigationHandler = null;
807
948
  }
808
949
  }
@@ -817,13 +958,12 @@ class HiddenEntry {
817
958
  }
818
959
  this._injectedElement = null;
819
960
  this.targetElement = null;
961
+ this._startBootstrapWatch();
962
+ this._scheduleFallback();
820
963
  setTimeout(() => {
821
964
  if (this._destroyed) return;
822
- const eligible = findEligibleEntryElements(
823
- this.config.hiddenEntry.selector,
824
- this.config.hiddenEntry.excludeSelectors
825
- );
826
- this._injectIntoExisting(eligible);
965
+ const ok = this._injectIntoExisting();
966
+ if (!ok) this._scheduleNextRetry();
827
967
  }, 1200);
828
968
  }
829
969
  /**
@@ -832,12 +972,22 @@ class HiddenEntry {
832
972
  * Returns true if inserted without layout shift.
833
973
  */
834
974
  _tryInsertBetweenWords(host, span) {
835
- var _a2;
836
975
  const textNodes = [];
837
- for (const node of host.childNodes) {
838
- if (node.nodeType === Node.TEXT_NODE && (((_a2 = node.textContent) == null ? void 0 : _a2.trim().length) ?? 0) > 5) {
839
- textNodes.push(node);
976
+ const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT, {
977
+ acceptNode: (node) => {
978
+ var _a2;
979
+ const text2 = ((_a2 = node.textContent) == null ? void 0 : _a2.trim()) ?? "";
980
+ if (text2.length <= 5) return NodeFilter.FILTER_REJECT;
981
+ const parent = node.parentElement;
982
+ if (!parent) return NodeFilter.FILTER_REJECT;
983
+ if (parent.closest("[data-eeq], script, style, noscript")) return NodeFilter.FILTER_REJECT;
984
+ return NodeFilter.FILTER_ACCEPT;
840
985
  }
986
+ });
987
+ let current = walker.nextNode();
988
+ while (current) {
989
+ textNodes.push(current);
990
+ current = walker.nextNode();
841
991
  }
842
992
  if (!textNodes.length) return false;
843
993
  const slotSeed = Math.floor(Date.now() / (1e3 * 60 * 2));
@@ -854,13 +1004,13 @@ class HiddenEntry {
854
1004
  const splitAt = spacePositions[Math.min(idx, spacePositions.length - 1)];
855
1005
  const before = text.slice(0, splitAt);
856
1006
  const after = text.slice(splitAt);
857
- const heightBefore = host.getBoundingClientRect().height;
1007
+ const rectBefore = host.getBoundingClientRect();
858
1008
  const afterNode = document.createTextNode(after);
859
1009
  textNode.textContent = before;
860
1010
  host.insertBefore(afterNode, textNode.nextSibling);
861
1011
  host.insertBefore(span, afterNode);
862
- const heightAfter = host.getBoundingClientRect().height;
863
- if (Math.abs(heightAfter - heightBefore) > 2) {
1012
+ const rectAfter = host.getBoundingClientRect();
1013
+ if (Math.abs(rectAfter.height - rectBefore.height) > 8 || Math.abs(rectAfter.width - rectBefore.width) > 24) {
864
1014
  host.removeChild(span);
865
1015
  host.removeChild(afterNode);
866
1016
  textNode.textContent = text;
@@ -1105,6 +1255,7 @@ class HiddenEntry {
1105
1255
  document.head.appendChild(this.shimmerStyle);
1106
1256
  }
1107
1257
  _createFallbackEntry() {
1258
+ if (this.fallbackButton) return;
1108
1259
  this.fallbackButton = document.createElement("button");
1109
1260
  this.fallbackButton.textContent = "·";
1110
1261
  this.fallbackButton.setAttribute("aria-label", "Hidden game entry");
@@ -3568,16 +3719,16 @@ const _ResultsRenderer = class _ResultsRenderer {
3568
3719
  inner.appendChild(badgesDiv);
3569
3720
  const invite = document.createElement("div");
3570
3721
  invite.className = "eeq-share-invite";
3571
- invite.textContent = "share your result with friends 🥚";
3722
+ invite.textContent = "share or copy your result";
3572
3723
  inner.appendChild(invite);
3573
3724
  const actions = document.createElement("div");
3574
3725
  actions.className = "eeq-results-actions";
3575
3726
  const shareBtn = document.createElement("button");
3576
3727
  shareBtn.className = "eeq-btn eeq-btn-share";
3577
- shareBtn.title = "share result";
3728
+ shareBtn.title = "share or copy result";
3578
3729
  shareBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 10.5a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm7-4a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4z" stroke="currentColor" stroke-width="1.2"/><path d="M6.3 9.2l3.4 1.6M6.3 7.8l3.4-1.6" stroke="currentColor" stroke-width="1.2"/></svg>';
3579
3730
  const shareLabel = document.createElement("span");
3580
- shareLabel.textContent = "share";
3731
+ shareLabel.textContent = "share / copy";
3581
3732
  shareBtn.appendChild(shareLabel);
3582
3733
  actions.appendChild(shareBtn);
3583
3734
  const finishBtn = document.createElement("button");
@@ -3811,6 +3962,7 @@ const _ResultsRenderer = class _ResultsRenderer {
3811
3962
  _copyShareImage(s) {
3812
3963
  var _a2;
3813
3964
  const canvas = this._renderCard(s);
3965
+ const shareText = this._buildShareText(s);
3814
3966
  const dataUrl = canvas.toDataURL("image/png");
3815
3967
  const byteString = atob(dataUrl.split(",")[1]);
3816
3968
  const ab = new ArrayBuffer(byteString.length);
@@ -3824,17 +3976,35 @@ const _ResultsRenderer = class _ResultsRenderer {
3824
3976
  navigator.share({
3825
3977
  files: [file],
3826
3978
  title: "Easter Egg Quest",
3827
- text: `«${s.behaviorProfile.title}»`
3979
+ text: shareText
3828
3980
  }).catch(() => {
3829
3981
  });
3830
3982
  return;
3831
3983
  }
3984
+ navigator.share({
3985
+ title: "Easter Egg Quest",
3986
+ text: shareText
3987
+ }).catch(() => {
3988
+ });
3989
+ return;
3832
3990
  }
3833
3991
  if (navigator.clipboard && typeof ClipboardItem !== "undefined") {
3834
3992
  navigator.clipboard.write([
3835
3993
  new ClipboardItem({ "image/png": blob })
3836
3994
  ]).then(() => {
3837
- this._flashButton(".eeq-btn-share", "copied!");
3995
+ this._flashButton(".eeq-btn-share", "image copied");
3996
+ }).catch(() => {
3997
+ this._copyShareText(shareText, blob);
3998
+ });
3999
+ return;
4000
+ }
4001
+ this._copyShareText(shareText, blob);
4002
+ }
4003
+ _copyShareText(text, blob) {
4004
+ var _a2;
4005
+ if ((_a2 = navigator.clipboard) == null ? void 0 : _a2.writeText) {
4006
+ navigator.clipboard.writeText(text).then(() => {
4007
+ this._flashButton(".eeq-btn-share", "text copied");
3838
4008
  }).catch(() => {
3839
4009
  this._downloadBlob(blob);
3840
4010
  });
@@ -3842,6 +4012,11 @@ const _ResultsRenderer = class _ResultsRenderer {
3842
4012
  }
3843
4013
  this._downloadBlob(blob);
3844
4014
  }
4015
+ _buildShareText(s) {
4016
+ const time = formatTime(s.totalTime);
4017
+ const eggs = `${s.eggsFound}/3 eggs`;
4018
+ return `Easter Egg Quest: ${s.behaviorProfile.title} • ${time} • ${eggs}`;
4019
+ }
3845
4020
  _downloadBlob(blob) {
3846
4021
  const url = URL.createObjectURL(blob);
3847
4022
  const a = document.createElement("a");
@@ -3921,10 +4096,10 @@ const _ResultsRenderer = class _ResultsRenderer {
3921
4096
  ctx.fillText(labels.join(" · "), W / 2, divY + 92);
3922
4097
  ctx.font = "14px Georgia, serif";
3923
4098
  ctx.fillStyle = "rgba(232,224,214,0.55)";
3924
- ctx.fillText("Happy Easter! 🐰🥚", W / 2, divY + 130);
4099
+ ctx.fillText("All three eggs found", W / 2, divY + 130);
3925
4100
  ctx.font = "11px Georgia, serif";
3926
4101
  ctx.fillStyle = "rgba(232,224,214,0.3)";
3927
- ctx.fillText("Wishing you joy, peace, and light", W / 2, divY + 150);
4102
+ ctx.fillText("Share or copy your result", W / 2, divY + 150);
3928
4103
  ctx.font = "9px Georgia, serif";
3929
4104
  ctx.fillStyle = "rgba(232,224,214,0.12)";
3930
4105
  ctx.fillText("easter egg quest", W / 2, H - 16);